feat: prune cloud providers and console API, complete Neuron branding sweep
This commit is contained in:
@@ -56,7 +56,6 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cerebras": "2.0.60",
|
||||
@@ -64,7 +63,6 @@
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.181",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
|
||||
@@ -104,7 +104,7 @@ export function make(input: {
|
||||
"terminal-auth": {
|
||||
command: "opencode",
|
||||
args: ["auth", "login"],
|
||||
label: "OpenCode Login",
|
||||
label: "Neuron Login",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export function make(input: {
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: {
|
||||
name: "OpenCode",
|
||||
name: "Neuron",
|
||||
version: InstallationVersion,
|
||||
},
|
||||
}
|
||||
@@ -874,7 +874,7 @@ const promptResponse = Effect.fn("ACP.promptResponse")(function* (
|
||||
|
||||
function promptErrorMessage(error: AssistantError) {
|
||||
if ("message" in error.data && typeof error.data.message === "string") return error.data.message
|
||||
return "OpenCode prompt failed"
|
||||
return "Neuron prompt failed"
|
||||
}
|
||||
|
||||
function sendUsageUpdate(
|
||||
@@ -1067,7 +1067,7 @@ function fromUnknownError(error: unknown, service?: string): Error {
|
||||
if (isAuthRequired(error)) {
|
||||
return new ACPError.AuthRequiredError({ providerId: findProviderID(error) })
|
||||
}
|
||||
return new ACPError.ServiceFailureError({ safeMessage: "OpenCode service failure", service })
|
||||
return new ACPError.ServiceFailureError({ safeMessage: "Neuron service failure", service })
|
||||
}
|
||||
|
||||
function isACPError(error: unknown): error is Error {
|
||||
|
||||
@@ -125,11 +125,11 @@ export function permissionInfo(request: PermissionRequest): PermissionInfo {
|
||||
|
||||
export function permissionAlwaysLines(request: PermissionRequest): string[] {
|
||||
if (request.always.length === 1 && request.always[0] === "*") {
|
||||
return [`This will allow ${request.permission} until OpenCode is restarted.`]
|
||||
return [`This will allow ${request.permission} until Neuron is restarted.`]
|
||||
}
|
||||
|
||||
return [
|
||||
"This will allow the following patterns until OpenCode is restarted.",
|
||||
"This will allow the following patterns until Neuron is restarted.",
|
||||
...request.always.map((item) => `- ${item}`),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
|
||||
})
|
||||
}
|
||||
|
||||
push(lines, body_left, top, "OpenCode", right, undefined, TextAttributes.BOLD)
|
||||
push(lines, body_left, top, "Neuron", right, undefined, TextAttributes.BOLD)
|
||||
if (input.detail) {
|
||||
push(
|
||||
lines,
|
||||
|
||||
@@ -55,7 +55,7 @@ export const UninstallCommand = {
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
prompts.intro("Uninstall OpenCode")
|
||||
prompts.intro("Uninstall Neuron")
|
||||
|
||||
const method = await Installation.method()
|
||||
prompts.log.info(`Installation method: ${method}`)
|
||||
@@ -229,7 +229,7 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
|
||||
}
|
||||
|
||||
UI.empty()
|
||||
prompts.log.success("Thank you for using OpenCode!")
|
||||
prompts.log.success("Thank you for using Neuron!")
|
||||
}
|
||||
|
||||
async function getShellConfigFile(): Promise<string | null> {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Process } from "@/util/process"
|
||||
|
||||
const MANAGED_PLIST_DOMAIN = "ai.opencode.managed"
|
||||
|
||||
// Keys injected by macOS/MDM into the managed plist that are not OpenCode config
|
||||
// Keys injected by macOS/MDM into the managed plist that are not Neuron config
|
||||
const PLIST_META = new Set([
|
||||
"PayloadDisplayName",
|
||||
"PayloadIdentifier",
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
This is a plugin to simulate a remote environment locally. Add this to `.opencode/opencode.jsonc`:
|
||||
|
||||
```json
|
||||
"plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"],
|
||||
```
|
||||
|
||||
In a separate terminal, run a separate OpenCode server. This will act like a remote server and the local instance will proxy all requests to it:
|
||||
|
||||
```
|
||||
./packages/opencode/script/run-workspace-server
|
||||
```
|
||||
|
||||
With the plugin install, you can now run OpenCode and create a `debug` workspace type. This will create a "remote" workspace which talks to the second workspace server started above.
|
||||
|
||||
How this works:
|
||||
|
||||
- The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written.
|
||||
- The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server.
|
||||
- The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist.
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { rename, writeFile } from "node:fs/promises"
|
||||
import { randomInt } from "node:crypto"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const DEV_DATA_FILE = "/tmp/opencode-workspace-dev-data.json"
|
||||
const DEV_DATA_TEMP_FILE = `${DEV_DATA_FILE}.tmp`
|
||||
|
||||
async function waitForHealth(port: number) {
|
||||
const url = `http://127.0.0.1:${port}/global/health`
|
||||
const started = Date.now()
|
||||
|
||||
while (Date.now() - started < 30_000) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (response.ok) {
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await sleep(250)
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for debug server health check at ${url}`)
|
||||
}
|
||||
|
||||
let PORT: number | undefined
|
||||
|
||||
async function writeDebugData(port: number, id: string, env: Record<string, string | undefined>) {
|
||||
await writeFile(
|
||||
DEV_DATA_TEMP_FILE,
|
||||
JSON.stringify(
|
||||
{
|
||||
port,
|
||||
id,
|
||||
env,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
await rename(DEV_DATA_TEMP_FILE, DEV_DATA_FILE)
|
||||
}
|
||||
|
||||
export const DebugWorkspacePlugin: Plugin = async ({ experimental_workspace }) => {
|
||||
experimental_workspace.register("debug", {
|
||||
name: "Debug",
|
||||
description: "Create a debugging server",
|
||||
configure(config) {
|
||||
return config
|
||||
},
|
||||
async create(config, env) {
|
||||
const port = randomInt(5000, 9001)
|
||||
PORT = port
|
||||
|
||||
await writeDebugData(port, config.id, env)
|
||||
|
||||
await waitForHealth(port)
|
||||
},
|
||||
async remove(_config) {},
|
||||
target(_config) {
|
||||
return {
|
||||
type: "remote",
|
||||
url: `http://localhost:${PORT!}/`,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
export default DebugWorkspacePlugin
|
||||
@@ -43,7 +43,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl],
|
||||
client_name: "OpenCode",
|
||||
client_name: "Neuron",
|
||||
client_uri: "https://opencode.ai",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
const prompts = []
|
||||
if (!process.env.AZURE_RESOURCE_NAME) {
|
||||
prompts.push({
|
||||
type: "text" as const,
|
||||
key: "resourceName",
|
||||
message: "Enter Azure Resource Name",
|
||||
placeholder: "e.g. my-models",
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
auth: {
|
||||
provider: "azure",
|
||||
methods: [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
prompts,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export async function CloudflareWorkersAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
const prompts = !process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
return {
|
||||
auth: {
|
||||
provider: "cloudflare-workers-ai",
|
||||
methods: [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
prompts,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function CloudflareAIGatewayAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
const prompts = [
|
||||
...(!process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!process.env.CLOUDFLARE_GATEWAY_ID
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "gatewayId",
|
||||
message: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return {
|
||||
auth: {
|
||||
provider: "cloudflare-ai-gateway",
|
||||
methods: [
|
||||
{
|
||||
type: "api",
|
||||
label: "Gateway API token",
|
||||
prompts,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
|
||||
return {
|
||||
url,
|
||||
instructions:
|
||||
"Sign in to DigitalOcean in your browser. OpenCode will use your DigitalOcean API token directly for inference and load your Inference Routers. Re-run /connect to refresh routers later.",
|
||||
"Sign in to DigitalOcean in your browser. Neuron will use your DigitalOcean API token directly for inference and load your Inference Routers. Re-run /connect to refresh routers later.",
|
||||
method: "auto" as const,
|
||||
async callback() {
|
||||
try {
|
||||
|
||||
@@ -12,15 +12,11 @@ import { ServerAuth } from "@/server/auth"
|
||||
import { CodexAuthPlugin } from "./openai/codex"
|
||||
import { Session } from "@/session/session"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { ModalPlugin } from "./modal/modal"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
||||
import { AzureAuthPlugin } from "./azure"
|
||||
import { DigitalOceanAuthPlugin } from "./digitalocean"
|
||||
import { XaiAuthPlugin } from "./xai"
|
||||
import { CerebrasPlugin } from "./cerebras"
|
||||
import { SnowflakeCortexAuthPlugin } from "./snowflake-cortex"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
@@ -70,14 +66,9 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
|
||||
CodexAuthPlugin(input, {
|
||||
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
|
||||
}),
|
||||
ModalPlugin,
|
||||
GitlabAuthPlugin,
|
||||
PoeAuthPlugin,
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
SnowflakeCortexAuthPlugin,
|
||||
XaiAuthPlugin,
|
||||
CerebrasPlugin,
|
||||
]
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { ModalModels } from "./models"
|
||||
|
||||
export async function ModalPlugin(): Promise<Hooks> {
|
||||
return {
|
||||
provider: {
|
||||
id: "modal",
|
||||
async models(provider, ctx) {
|
||||
const apiKey = ctx.auth?.type === "api" ? ctx.auth.key : process.env.MODAL_PROXY_TOKEN
|
||||
const baseURL = Object.values(provider.models)[0]?.api.url
|
||||
if (!apiKey || !baseURL) return {}
|
||||
|
||||
return ModalModels.get(baseURL, apiKey, provider.models).catch(() => ({}))
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { Model } from "@opencode-ai/sdk/v2"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const reasoningOption = Schema.Struct({
|
||||
type: Schema.Literal("effort"),
|
||||
values: Schema.Array(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
|
||||
const response = Schema.Struct({
|
||||
data: Schema.Array(
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
base_model_id: Schema.optional(Schema.String),
|
||||
hugging_face_id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
input_modalities: Schema.optional(Schema.Array(Schema.String)),
|
||||
output_modalities: Schema.optional(Schema.Array(Schema.String)),
|
||||
context_length: Schema.optional(Schema.Number),
|
||||
max_output_length: Schema.optional(Schema.Number),
|
||||
pricing: Schema.optional(
|
||||
Schema.Struct({
|
||||
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
}),
|
||||
),
|
||||
supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)),
|
||||
supported_features: Schema.optional(Schema.Array(Schema.String)),
|
||||
reasoning_options: Schema.optional(Schema.Array(reasoningOption)),
|
||||
interleaved: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Boolean,
|
||||
Schema.Struct({
|
||||
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownSync(response)
|
||||
|
||||
function price(value: string | number | undefined, fallback: number) {
|
||||
if (value === undefined) return fallback
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed * 1_000_000 : fallback
|
||||
}
|
||||
|
||||
export async function get(baseURL: string, apiKey: string, existing: Record<string, Model>) {
|
||||
const data = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) throw new Error(`Failed to fetch Modal models: ${res.status}`)
|
||||
return decode(await res.json())
|
||||
})
|
||||
|
||||
return Object.fromEntries(
|
||||
data.data.map((item) => {
|
||||
const template = existing[item.base_model_id ?? item.hugging_face_id ?? item.id]
|
||||
const model: Model = {
|
||||
id: item.id,
|
||||
providerID: "modal",
|
||||
name: item.name ?? template?.name ?? item.id,
|
||||
family: template?.family,
|
||||
api: {
|
||||
id: item.id,
|
||||
url: baseURL,
|
||||
npm: template?.api.npm ?? "@ai-sdk/openai-compatible",
|
||||
},
|
||||
status: template?.status ?? "active",
|
||||
headers: { ...template?.headers },
|
||||
options: { ...template?.options },
|
||||
cost: {
|
||||
input: price(item.pricing?.prompt, template?.cost.input ?? 0),
|
||||
output: price(item.pricing?.completion, template?.cost.output ?? 0),
|
||||
cache: {
|
||||
read: price(item.pricing?.input_cache_read, template?.cost.cache.read ?? 0),
|
||||
write: template?.cost.cache.write ?? 0,
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
context: item.context_length ?? template?.limit.context ?? 0,
|
||||
input: template?.limit.input,
|
||||
output: item.max_output_length ?? template?.limit.output ?? 0,
|
||||
},
|
||||
capabilities: {
|
||||
temperature:
|
||||
item.supported_sampling_parameters?.includes("temperature") ?? template?.capabilities.temperature ?? false,
|
||||
reasoning: item.supported_features?.includes("reasoning") ?? template?.capabilities.reasoning ?? false,
|
||||
attachment:
|
||||
item.input_modalities?.some((modality) => modality !== "text") ??
|
||||
template?.capabilities.attachment ??
|
||||
false,
|
||||
toolcall: item.supported_features?.includes("tools") ?? template?.capabilities.toolcall ?? true,
|
||||
input: {
|
||||
text: item.input_modalities?.includes("text") ?? template?.capabilities.input.text ?? true,
|
||||
audio: item.input_modalities?.includes("audio") ?? template?.capabilities.input.audio ?? false,
|
||||
image: item.input_modalities?.includes("image") ?? template?.capabilities.input.image ?? false,
|
||||
video: item.input_modalities?.includes("video") ?? template?.capabilities.input.video ?? false,
|
||||
pdf: item.input_modalities?.includes("pdf") ?? template?.capabilities.input.pdf ?? false,
|
||||
},
|
||||
output: {
|
||||
text: item.output_modalities?.includes("text") ?? template?.capabilities.output.text ?? true,
|
||||
audio: item.output_modalities?.includes("audio") ?? template?.capabilities.output.audio ?? false,
|
||||
image: item.output_modalities?.includes("image") ?? template?.capabilities.output.image ?? false,
|
||||
video: item.output_modalities?.includes("video") ?? template?.capabilities.output.video ?? false,
|
||||
pdf: item.output_modalities?.includes("pdf") ?? template?.capabilities.output.pdf ?? false,
|
||||
},
|
||||
interleaved: item.interleaved ?? template?.capabilities.interleaved ?? false,
|
||||
},
|
||||
release_date: template?.release_date ?? "",
|
||||
}
|
||||
model.variants =
|
||||
item.reasoning_options === undefined
|
||||
? template?.variants
|
||||
: Object.fromEntries(
|
||||
item.reasoning_options.flatMap((option) =>
|
||||
option.values.map((value) => {
|
||||
const effort = value ?? "none"
|
||||
return [effort, { reasoningEffort: effort }]
|
||||
}),
|
||||
),
|
||||
)
|
||||
return [item.id, model]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export * as ModalModels from "./models"
|
||||
@@ -1,507 +0,0 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
import { createServer } from "http"
|
||||
import open from "open"
|
||||
|
||||
const OAUTH_CLIENT_ID = "LOCAL_APPLICATION"
|
||||
const OAUTH_CALLBACK_HOST = "127.0.0.1"
|
||||
const OAUTH_CALLBACK_PATH = "/"
|
||||
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000
|
||||
const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000
|
||||
|
||||
interface PkceCodes {
|
||||
verifier: string
|
||||
challenge: string
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
interface PendingOAuth {
|
||||
account: string
|
||||
state: string
|
||||
pkce: PkceCodes
|
||||
resolve: (tokens: TokenResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
let oauthServerPort: number | undefined
|
||||
|
||||
function normalizeAccount(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\.snowflakecomputing\.com\/?$/, "")
|
||||
.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function generateRandomString(length: number) {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(length)))
|
||||
.map((b) => chars[b % chars.length])
|
||||
.join("")
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer) {
|
||||
const binary = String.fromCharCode(...new Uint8Array(buffer))
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<PkceCodes> {
|
||||
const verifier = generateRandomString(64)
|
||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
|
||||
return {
|
||||
verifier,
|
||||
challenge: base64UrlEncode(hash),
|
||||
}
|
||||
}
|
||||
|
||||
function callbackUrl() {
|
||||
if (!oauthServerPort) throw new Error("Snowflake OAuth callback server is not running")
|
||||
return `http://${OAUTH_CALLBACK_HOST}:${oauthServerPort}${OAUTH_CALLBACK_PATH}`
|
||||
}
|
||||
|
||||
export function oauthScope(role: string | undefined) {
|
||||
if (!role) return "refresh_token"
|
||||
return /^[-_A-Za-z0-9]+$/.test(role)
|
||||
? `refresh_token session:role:${role}`
|
||||
: `refresh_token session:role-encoded:${encodeURIComponent(role)}`
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
}
|
||||
}
|
||||
|
||||
function authBasicHeader() {
|
||||
return `Basic ${Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_ID}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl(account: string, role: string | undefined, state: string, pkce: PkceCodes) {
|
||||
const scope = oauthScope(role)
|
||||
const params = new URLSearchParams({
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: callbackUrl(),
|
||||
scope,
|
||||
state,
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
})
|
||||
return `https://${account}.snowflakecomputing.com/oauth/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(account: string, code: string, pkce: PkceCodes) {
|
||||
const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
Authorization: authBasicHeader(),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: callbackUrl(),
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
code_verifier: pkce.verifier,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw new Error(`Snowflake token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
|
||||
const token = (await response.json()) as TokenResponse
|
||||
if (!token.access_token) throw new Error("Snowflake token response did not include access_token")
|
||||
if (!token.refresh_token) {
|
||||
throw new Error(
|
||||
"Snowflake token response did not include refresh_token. Ensure integration issues refresh tokens and scope includes refresh_token.",
|
||||
)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
async function refreshAccessToken(account: string, refreshToken: string) {
|
||||
const response = await fetch(`https://${account}.snowflakecomputing.com/oauth/token-request`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
Authorization: authBasicHeader(),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw new Error(`Snowflake token refresh failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
|
||||
const token = (await response.json()) as TokenResponse
|
||||
if (!token.access_token) throw new Error("Snowflake refresh response did not include access_token")
|
||||
return token
|
||||
}
|
||||
|
||||
async function startOAuthServer() {
|
||||
if (oauthServer) return
|
||||
|
||||
oauthServer = createServer((req, res) => {
|
||||
const host = req.headers.host || `${OAUTH_CALLBACK_HOST}:${oauthServerPort ?? 0}`
|
||||
const url = new URL(req.url || "/", `http://${host}`)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
}
|
||||
|
||||
const state = url.searchParams.get("state")
|
||||
const code = url.searchParams.get("code")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
// CSRF guard: validate state before processing any callback
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const message = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(message))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
if (error) {
|
||||
const message = errorDescription || error
|
||||
current.reject(new Error(message))
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const message = "Missing authorization code"
|
||||
current.reject(new Error(message))
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(message, { provider: "Snowflake" }))
|
||||
return
|
||||
}
|
||||
|
||||
exchangeCodeForToken(current.account, code, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.success({ provider: "Snowflake" }))
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
oauthServer!.listen(0, OAUTH_CALLBACK_HOST, () => {
|
||||
const address = oauthServer!.address()
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Unable to resolve Snowflake OAuth callback port"))
|
||||
return
|
||||
}
|
||||
oauthServerPort = address.port
|
||||
resolve()
|
||||
})
|
||||
oauthServer!.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (!oauthServer) return
|
||||
oauthServer.close()
|
||||
oauthServer = undefined
|
||||
oauthServerPort = undefined
|
||||
}
|
||||
|
||||
function waitForOAuthCallback(account: string, pkce: PkceCodes, state: string): Promise<TokenResponse> {
|
||||
if (pendingOAuth) {
|
||||
pendingOAuth.reject(new Error("Superseded by a newer Snowflake authorize request"))
|
||||
pendingOAuth = undefined
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!pendingOAuth) return
|
||||
pendingOAuth = undefined
|
||||
stopOAuthServer()
|
||||
reject(new Error("Snowflake OAuth callback timeout - authorization took too long"))
|
||||
}, OAUTH_TIMEOUT_MS)
|
||||
|
||||
pendingOAuth = {
|
||||
account,
|
||||
state,
|
||||
pkce,
|
||||
resolve: (tokens) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(tokens)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
const prompts = [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "account",
|
||||
message: "Snowflake Account Identifier",
|
||||
placeholder: "myorg-myaccount",
|
||||
validate: (value: string) => (value && value.trim().length > 0 ? undefined : "Required"),
|
||||
},
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "role",
|
||||
message: "Snowflake Role (optional)",
|
||||
placeholder: "PUBLIC",
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
auth: {
|
||||
provider: "snowflake-cortex",
|
||||
async loader(getAuth, _provider) {
|
||||
let auth = await getAuth()
|
||||
if (auth.type !== "oauth") return {}
|
||||
|
||||
let refreshPromise:
|
||||
| Promise<{
|
||||
access: string
|
||||
refresh: string
|
||||
expires: number
|
||||
}>
|
||||
| undefined
|
||||
|
||||
const oauth = auth as typeof auth & { refresh: string; access: string; expires: number; accountId?: string }
|
||||
|
||||
if (oauth.accountId && oauth.refresh && oauth.expires && oauth.expires <= Date.now()) {
|
||||
try {
|
||||
const tokens = await refreshAccessToken(oauth.accountId, oauth.refresh)
|
||||
const refreshedRefresh = tokens.refresh_token || oauth.refresh
|
||||
const refreshedExpires = Date.now() + (tokens.expires_in ?? 600) * 1000
|
||||
await _input.client.auth
|
||||
.set({
|
||||
path: { id: "snowflake-cortex" },
|
||||
body: {
|
||||
type: "oauth",
|
||||
access: tokens.access_token,
|
||||
refresh: refreshedRefresh,
|
||||
expires: refreshedExpires,
|
||||
...(oauth.accountId && { accountId: oauth.accountId }),
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: OAUTH_DUMMY_KEY,
|
||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
let currentAuth = await getAuth()
|
||||
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||
let currentOauth = currentAuth as typeof currentAuth & {
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
accountId?: string
|
||||
}
|
||||
|
||||
if (!currentOauth.accountId) throw new Error("Snowflake OAuth auth is missing accountId")
|
||||
const accountId = currentOauth.accountId
|
||||
|
||||
const refresh = async () => {
|
||||
if (!refreshPromise) {
|
||||
const refreshToken = currentOauth.refresh
|
||||
refreshPromise = refreshAccessToken(accountId, refreshToken)
|
||||
.then(async (tokens) => {
|
||||
const refreshedRefresh = tokens.refresh_token || refreshToken
|
||||
const refreshedExpires = Date.now() + (tokens.expires_in ?? 600) * 1000
|
||||
await _input.client.auth
|
||||
.set({
|
||||
path: { id: "snowflake-cortex" },
|
||||
body: {
|
||||
type: "oauth",
|
||||
access: tokens.access_token,
|
||||
refresh: refreshedRefresh,
|
||||
expires: refreshedExpires,
|
||||
...(accountId && { accountId }),
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
return {
|
||||
access: tokens.access_token,
|
||||
refresh: refreshedRefresh,
|
||||
expires: refreshedExpires,
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = undefined
|
||||
})
|
||||
}
|
||||
|
||||
const refreshed = await refreshPromise
|
||||
currentOauth = { ...currentOauth, ...refreshed }
|
||||
}
|
||||
|
||||
const prepareRequest = () => {
|
||||
const headers = new Headers(requestInput instanceof Request ? requestInput.headers : undefined)
|
||||
if (init?.headers) {
|
||||
const entries =
|
||||
init.headers instanceof Headers
|
||||
? init.headers.entries()
|
||||
: Array.isArray(init.headers)
|
||||
? init.headers
|
||||
: Object.entries(init.headers as Record<string, string | undefined>)
|
||||
for (const [key, value] of entries) {
|
||||
if (value !== undefined) headers.set(key, String(value))
|
||||
}
|
||||
}
|
||||
headers.set("authorization", `Bearer ${currentOauth.access}`)
|
||||
headers.set("User-Agent", `opencode/${InstallationVersion}`)
|
||||
|
||||
let body = init?.body
|
||||
if (body && typeof body === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(body)
|
||||
if ("max_tokens" in parsed) {
|
||||
parsed.max_completion_tokens = parsed.max_tokens
|
||||
delete parsed.max_tokens
|
||||
body = JSON.stringify(parsed)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return { ...init, headers, body }
|
||||
}
|
||||
|
||||
const transformResponse = async (response: Response) => {
|
||||
if (!response.ok && response.status === 400) {
|
||||
try {
|
||||
const errorData = await response.clone().json()
|
||||
const errorMessage = String(errorData.message || errorData.error || "")
|
||||
if (errorMessage.toLowerCase().includes("conversation complete")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }],
|
||||
}),
|
||||
{ status: 200, headers: new Headers({ "content-type": "application/json" }) },
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) {
|
||||
const reader = response.body.getReader()
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
const stream = new ReadableStream({
|
||||
async pull(ctrl) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
ctrl.close()
|
||||
return
|
||||
}
|
||||
const text = decoder.decode(value, { stream: true })
|
||||
ctrl.enqueue(encoder.encode(text.replace(/"role"\s*:\s*""/g, '"role":"assistant"')))
|
||||
},
|
||||
cancel() {
|
||||
reader.cancel()
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: response.headers, status: response.status })
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
const expiresSoon =
|
||||
!currentOauth.expires ||
|
||||
!currentOauth.access ||
|
||||
currentOauth.expires - Date.now() <= ACCESS_TOKEN_REFRESH_SKEW_MS
|
||||
|
||||
if (expiresSoon) await refresh()
|
||||
|
||||
const response = await fetch(requestInput, prepareRequest())
|
||||
|
||||
if (response.status === 401) {
|
||||
await refresh()
|
||||
return transformResponse(await fetch(requestInput, prepareRequest()))
|
||||
}
|
||||
|
||||
return transformResponse(response)
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: [
|
||||
{
|
||||
type: "oauth",
|
||||
label: "Login with Snowflake (External Browser)",
|
||||
prompts,
|
||||
async authorize(inputs = {}) {
|
||||
const account = normalizeAccount(inputs.account || "")
|
||||
if (!account) throw new Error("Snowflake account is required")
|
||||
|
||||
await startOAuthServer()
|
||||
const pkce = await generatePKCE()
|
||||
const state = generateRandomString(64)
|
||||
const role = (inputs.role || "").trim() || undefined
|
||||
const url = buildAuthorizeUrl(account, role, state, pkce)
|
||||
const callbackPromise = waitForOAuthCallback(account, pkce, state)
|
||||
await open(url).catch(() => undefined)
|
||||
|
||||
return {
|
||||
url,
|
||||
instructions:
|
||||
"Complete Snowflake sign-in in your browser. OpenCode will capture the OAuth callback and store the bearer token automatically.",
|
||||
method: "auto" as const,
|
||||
async callback() {
|
||||
try {
|
||||
const tokens = await callbackPromise
|
||||
return {
|
||||
type: "success" as const,
|
||||
refresh: tokens.refresh_token!,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 600) * 1000,
|
||||
accountId: account,
|
||||
}
|
||||
} catch {
|
||||
return { type: "failed" as const }
|
||||
} finally {
|
||||
stopOAuthServer()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "api",
|
||||
label: "Paste PAT or bearer token manually",
|
||||
prompts: prompts.filter((item) => item.key === "account"),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -111,14 +111,9 @@ type BundledSDK = {
|
||||
}
|
||||
|
||||
const BUNDLED_PROVIDERS: Record<string, () => Promise<(opts: any) => BundledSDK>> = {
|
||||
"@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then((m) => m.createAmazonBedrock),
|
||||
"@ai-sdk/amazon-bedrock/mantle": () => import("@ai-sdk/amazon-bedrock/mantle").then((m) => m.createBedrockMantle),
|
||||
"@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic),
|
||||
"@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure),
|
||||
"@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI),
|
||||
"@ai-sdk/google-vertex": () => import("@ai-sdk/google-vertex").then((m) => m.createVertex),
|
||||
"@ai-sdk/google-vertex/anthropic": () =>
|
||||
import("@ai-sdk/google-vertex/anthropic").then((m) => m.createVertexAnthropic),
|
||||
"@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI),
|
||||
"@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible),
|
||||
"@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter),
|
||||
@@ -267,178 +262,6 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
},
|
||||
}
|
||||
}),
|
||||
"azure-cognitive-services": Effect.fnUntraced(function* (provider: Info) {
|
||||
const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
|
||||
},
|
||||
options: {
|
||||
baseURL: resourceName
|
||||
? `https://${resourceName}.cognitiveservices.azure.com/openai${provider.options?.useDeploymentBasedUrls ? "" : "/v1"}`
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
"amazon-bedrock": Effect.fnUntraced(function* () {
|
||||
const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
|
||||
const auth = yield* dep.auth("amazon-bedrock")
|
||||
const env = yield* dep.env()
|
||||
|
||||
// Region precedence: 1) config file, 2) env var, 3) default
|
||||
const configRegion = providerConfig?.options?.region
|
||||
const envRegion = env["AWS_REGION"]
|
||||
const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
|
||||
|
||||
// Profile: config file takes precedence over env var
|
||||
const configProfile = providerConfig?.options?.profile
|
||||
const envProfile = env["AWS_PROFILE"]
|
||||
const profile = configProfile ?? envProfile
|
||||
|
||||
const awsAccessKeyId = env["AWS_ACCESS_KEY_ID"]
|
||||
const configApiKey = providerConfig?.options?.apiKey
|
||||
|
||||
// the AWS SDK reads this from process.env lazily at request time, so go
|
||||
// through Env.set which writes through to process.env
|
||||
let awsBearerToken = process.env.AWS_BEARER_TOKEN_BEDROCK
|
||||
if (!awsBearerToken && auth?.type === "api") {
|
||||
yield* dep.set("AWS_BEARER_TOKEN_BEDROCK", auth.key)
|
||||
awsBearerToken = auth.key
|
||||
}
|
||||
|
||||
const awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"]
|
||||
|
||||
const containerCreds = Boolean(
|
||||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
|
||||
)
|
||||
|
||||
if (
|
||||
!profile &&
|
||||
!awsAccessKeyId &&
|
||||
!awsBearerToken &&
|
||||
!configApiKey &&
|
||||
!awsWebIdentityTokenFile &&
|
||||
!containerCreds
|
||||
)
|
||||
return { autoload: false }
|
||||
|
||||
const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
|
||||
|
||||
const providerOptions: Record<string, any> = {
|
||||
region: defaultRegion,
|
||||
}
|
||||
|
||||
// Only use credential chain if no bearer token exists
|
||||
// Bearer token takes precedence over credential chain (profiles, access keys, IAM roles, web identity tokens)
|
||||
if (!awsBearerToken && !configApiKey) {
|
||||
// Build credential provider options (only pass profile if specified)
|
||||
const credentialProviderOptions = profile ? { profile } : {}
|
||||
|
||||
providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
|
||||
}
|
||||
|
||||
// Add custom endpoint if specified (endpoint takes precedence over baseURL)
|
||||
const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
|
||||
if (endpoint) {
|
||||
providerOptions.baseURL = endpoint
|
||||
}
|
||||
|
||||
return {
|
||||
autoload: true,
|
||||
options: providerOptions,
|
||||
vars(options: Record<string, any>) {
|
||||
return { AWS_REGION: options.region ?? defaultRegion }
|
||||
},
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>, model?: Model) {
|
||||
if (model?.api.npm === "@ai-sdk/amazon-bedrock/mantle") return selectBedrockMantleLanguageModel(sdk, modelID)
|
||||
|
||||
// Skip region prefixing if model already has a cross-region inference profile prefix
|
||||
// Models from models.dev may already include prefixes like us., eu., global., etc.
|
||||
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
|
||||
if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) {
|
||||
return sdk.languageModel(modelID)
|
||||
}
|
||||
|
||||
// Region resolution precedence (highest to lowest):
|
||||
// 1. options.region from opencode.json provider config
|
||||
// 2. defaultRegion from AWS_REGION environment variable
|
||||
// 3. Default "us-east-1" (baked into defaultRegion)
|
||||
const region = options?.region ?? defaultRegion
|
||||
|
||||
let regionPrefix = region.split("-")[0]
|
||||
|
||||
switch (regionPrefix) {
|
||||
case "us": {
|
||||
const modelRequiresPrefix = [
|
||||
"nova-micro",
|
||||
"nova-lite",
|
||||
"nova-pro",
|
||||
"nova-premier",
|
||||
"nova-2",
|
||||
"claude",
|
||||
"deepseek",
|
||||
].some((m) => modelID.includes(m))
|
||||
const isGovCloud = region.startsWith("us-gov")
|
||||
if (modelRequiresPrefix && !isGovCloud) {
|
||||
modelID = `${regionPrefix}.${modelID}`
|
||||
}
|
||||
break
|
||||
}
|
||||
case "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((r) => region.includes(r))
|
||||
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) =>
|
||||
modelID.includes(m),
|
||||
)
|
||||
if (regionRequiresPrefix && modelRequiresPrefix) {
|
||||
modelID = `${regionPrefix}.${modelID}`
|
||||
}
|
||||
break
|
||||
}
|
||||
case "ap": {
|
||||
const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region)
|
||||
const isTokyoRegion = region === "ap-northeast-1"
|
||||
if (
|
||||
isAustraliaRegion &&
|
||||
["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m))
|
||||
) {
|
||||
regionPrefix = "au"
|
||||
modelID = `${regionPrefix}.${modelID}`
|
||||
} else if (isTokyoRegion) {
|
||||
// Tokyo region uses jp. prefix for cross-region inference
|
||||
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
|
||||
modelID.includes(m),
|
||||
)
|
||||
if (modelRequiresPrefix) {
|
||||
regionPrefix = "jp"
|
||||
modelID = `${regionPrefix}.${modelID}`
|
||||
}
|
||||
} else {
|
||||
// Other APAC regions use apac. prefix
|
||||
const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) =>
|
||||
modelID.includes(m),
|
||||
)
|
||||
if (modelRequiresPrefix) {
|
||||
regionPrefix = "apac"
|
||||
modelID = `${regionPrefix}.${modelID}`
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return sdk.languageModel(modelID)
|
||||
},
|
||||
}
|
||||
}),
|
||||
llmgateway: () =>
|
||||
Effect.succeed({
|
||||
autoload: false,
|
||||
@@ -467,7 +290,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
headers: {
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "Neuron",
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -481,97 +304,6 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
},
|
||||
},
|
||||
}),
|
||||
"google-vertex": Effect.fnUntraced(function* (provider: Info) {
|
||||
const env = yield* dep.env()
|
||||
// models.dev advertises GOOGLE_VERTEX_PROJECT for Vertex; keep the wider
|
||||
// Google Cloud project env names as fallbacks for existing ADC setups.
|
||||
const project =
|
||||
provider.options?.project ??
|
||||
env["GOOGLE_VERTEX_PROJECT"] ??
|
||||
env["GOOGLE_CLOUD_PROJECT"] ??
|
||||
env["GCP_PROJECT"] ??
|
||||
env["GCLOUD_PROJECT"]
|
||||
|
||||
const location = String(
|
||||
provider.options?.location ??
|
||||
env["GOOGLE_VERTEX_LOCATION"] ??
|
||||
env["GOOGLE_CLOUD_LOCATION"] ??
|
||||
env["VERTEX_LOCATION"] ??
|
||||
"us-central1",
|
||||
)
|
||||
|
||||
const autoload = Boolean(project)
|
||||
if (!autoload) return { autoload: false }
|
||||
return {
|
||||
autoload: true,
|
||||
vars(_options: Record<string, any>) {
|
||||
return {
|
||||
...(project && { GOOGLE_VERTEX_PROJECT: project }),
|
||||
GOOGLE_VERTEX_LOCATION: location,
|
||||
GOOGLE_VERTEX_ENDPOINT: googleVertexEndpoint(location),
|
||||
}
|
||||
},
|
||||
options: {
|
||||
project,
|
||||
location,
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const { GoogleAuth } = await import("google-auth-library")
|
||||
const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] })
|
||||
const client = await auth.getClient()
|
||||
const token = await client.getAccessToken()
|
||||
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set("Authorization", `Bearer ${token.token}`)
|
||||
|
||||
return fetch(input, { ...init, headers })
|
||||
},
|
||||
},
|
||||
async getModel(sdk: any, modelID: string) {
|
||||
const id = String(modelID).trim()
|
||||
return sdk.languageModel(id)
|
||||
},
|
||||
}
|
||||
}),
|
||||
"google-vertex-anthropic": Effect.fnUntraced(function* () {
|
||||
const env = yield* dep.env()
|
||||
const project = env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
|
||||
const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global"
|
||||
const autoload = Boolean(project)
|
||||
if (!autoload) return { autoload: false }
|
||||
const baseURL = googleVertexAnthropicBaseURL(project, location)
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
project,
|
||||
location,
|
||||
...(baseURL && { baseURL }),
|
||||
},
|
||||
async getModel(sdk: any, modelID) {
|
||||
const id = String(modelID).trim()
|
||||
return sdk.languageModel(id)
|
||||
},
|
||||
}
|
||||
}),
|
||||
"sap-ai-core": Effect.fnUntraced(function* () {
|
||||
const auth = yield* dep.auth("sap-ai-core")
|
||||
// the SAP SDK reads this from process.env lazily at request time, so go
|
||||
// through Env.set which writes through to process.env
|
||||
let envServiceKey = process.env.AICORE_SERVICE_KEY
|
||||
if (!envServiceKey && auth?.type === "api") {
|
||||
yield* dep.set("AICORE_SERVICE_KEY", auth.key)
|
||||
envServiceKey = auth.key
|
||||
}
|
||||
const deploymentId = process.env.AICORE_DEPLOYMENT_ID
|
||||
const resourceGroup = process.env.AICORE_RESOURCE_GROUP
|
||||
|
||||
return {
|
||||
autoload: !!envServiceKey,
|
||||
options: envServiceKey ? { deploymentId, resourceGroup } : {},
|
||||
async getModel(sdk: any, modelID: string) {
|
||||
return sdk(modelID)
|
||||
},
|
||||
}
|
||||
}),
|
||||
zenmux: () =>
|
||||
Effect.succeed({
|
||||
autoload: false,
|
||||
@@ -707,132 +439,6 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
},
|
||||
}
|
||||
}),
|
||||
"cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) {
|
||||
// When baseURL is already configured (e.g. corporate config routing through a proxy/gateway),
|
||||
// skip the account ID check because the URL is already fully specified.
|
||||
if (input.options?.baseURL) return { autoload: false }
|
||||
|
||||
const auth = yield* dep.auth(input.id)
|
||||
const env = yield* dep.env()
|
||||
const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||
if (!accountId)
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel() {
|
||||
throw new Error(
|
||||
"CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=<your-account-id>",
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const apiKey = env["CLOUDFLARE_API_KEY"] || (auth?.type === "api" ? auth.key : undefined)
|
||||
|
||||
return {
|
||||
autoload: !!apiKey,
|
||||
options: {
|
||||
apiKey,
|
||||
headers: {
|
||||
"User-Agent": `opencode/${InstallationVersion} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
},
|
||||
},
|
||||
async getModel(sdk: any, modelID: string) {
|
||||
return sdk.languageModel(modelID)
|
||||
},
|
||||
vars(_options) {
|
||||
return {
|
||||
CLOUDFLARE_ACCOUNT_ID: accountId,
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
"cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) {
|
||||
// When baseURL is already configured (e.g. corporate config), skip the ID checks.
|
||||
if (input.options?.baseURL) return { autoload: false }
|
||||
|
||||
const auth = yield* dep.auth(input.id)
|
||||
const env = yield* dep.env()
|
||||
const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||
// The Cloudflare auth prompt stores this value as gatewayId metadata.
|
||||
const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
|
||||
|
||||
if (!accountId || !gateway) {
|
||||
const missing = [
|
||||
!accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined,
|
||||
!gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined,
|
||||
].filter((x): x is string => Boolean(x))
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel() {
|
||||
throw new Error(
|
||||
`${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=<value>`).join(" && ")}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Get API token from env or auth - required for authenticated gateways
|
||||
const apiToken =
|
||||
env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"] || (auth?.type === "api" ? auth.key : undefined)
|
||||
|
||||
if (!apiToken) {
|
||||
throw new Error(
|
||||
"CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " +
|
||||
"Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.",
|
||||
)
|
||||
}
|
||||
|
||||
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider"))
|
||||
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified"))
|
||||
const { createOpenAI } = yield* Effect.promise(() => import("ai-gateway-provider/providers/openai"))
|
||||
const { createAnthropic } = yield* Effect.promise(() => import("ai-gateway-provider/providers/anthropic"))
|
||||
|
||||
const metadata = iife(() => {
|
||||
if (input.options?.metadata) return input.options.metadata
|
||||
try {
|
||||
return JSON.parse(input.options?.headers?.["cf-aig-metadata"])
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
const opts = {
|
||||
metadata,
|
||||
cacheTtl: input.options?.cacheTtl,
|
||||
cacheKey: input.options?.cacheKey,
|
||||
skipCache: input.options?.skipCache,
|
||||
collectLog: input.options?.collectLog,
|
||||
headers: {
|
||||
"User-Agent": `opencode/${InstallationVersion} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
},
|
||||
}
|
||||
|
||||
const aigateway = createAiGateway({
|
||||
accountId,
|
||||
gateway,
|
||||
apiKey: apiToken,
|
||||
...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}),
|
||||
})
|
||||
return {
|
||||
autoload: true,
|
||||
async getModel(_sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
// Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5").
|
||||
// OpenAI and Anthropic ride their native passthrough routes so agents get the Responses
|
||||
// and Messages APIs; new OpenAI models reject tools+reasoning_effort on chat completions.
|
||||
// The passthrough wrappers inject a CF_TEMP_TOKEN sentinel that the gateway strips before
|
||||
// dispatch, so upstream billing stays on the gateway (Unified Billing / stored BYOK).
|
||||
if (modelID.startsWith("openai/")) return aigateway(createOpenAI()(modelID.slice("openai/".length)))
|
||||
if (modelID.startsWith("anthropic/")) return aigateway(createAnthropic()(modelID.slice("anthropic/".length)))
|
||||
// Workers AI is the only first-party provider whose upstream is Cloudflare itself, so it is
|
||||
// the only one that should receive the Cloudflare token as its upstream Authorization header.
|
||||
// The Unified API addresses Workers AI both with the explicit "workers-ai/" prefix and as
|
||||
// bare "@cf/..." ids. Third-party providers must not receive the token; they rely on the
|
||||
// gateway's stored/BYOK keys instead.
|
||||
const isWorkersAi = modelID.startsWith("workers-ai/") || modelID.startsWith("@cf/")
|
||||
const unified = createUnified(isWorkersAi ? { apiKey: apiToken } : {})
|
||||
return aigateway(unified(modelID))
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
}),
|
||||
cerebras: () =>
|
||||
Effect.succeed({
|
||||
autoload: false,
|
||||
@@ -852,106 +458,6 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
},
|
||||
},
|
||||
}),
|
||||
"snowflake-cortex": Effect.fnUntraced(function* (input: Info) {
|
||||
const env = yield* dep.env()
|
||||
const auth = yield* dep.auth(input.id)
|
||||
|
||||
const account =
|
||||
env["SNOWFLAKE_ACCOUNT"] ??
|
||||
(auth?.type === "api" ? auth.metadata?.account : undefined) ??
|
||||
(auth?.type === "oauth" ? auth.accountId : undefined) ??
|
||||
input.options?.account
|
||||
|
||||
const envToken = env["SNOWFLAKE_CORTEX_TOKEN"] ?? env["SNOWFLAKE_CORTEX_PAT"]
|
||||
const apiKeyToken = auth?.type === "api" ? auth.key : undefined
|
||||
const oauthToken = auth?.type === "oauth" ? auth.access : undefined
|
||||
const configToken = input.options?.token ?? input.options?.apiKey
|
||||
|
||||
const token = envToken ?? apiKeyToken ?? oauthToken ?? configToken
|
||||
|
||||
if (!account || !token) {
|
||||
const missing = [!account && "SNOWFLAKE_ACCOUNT", !token && "SNOWFLAKE_CORTEX_TOKEN"].filter(Boolean).join(", ")
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel() {
|
||||
throw new Error(
|
||||
`Snowflake Cortex: missing credentials (${missing}). Provide a bearer token (OAuth, JWT, or PAT) via env var, opencode auth, or provider options.`,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const baseURL = `https://${account}.snowflakecomputing.com/api/v2/cortex/v1`
|
||||
|
||||
const options: Record<string, any> = { baseURL, apiKey: token }
|
||||
|
||||
// Only skip provider-level fetch when the token is from OAuth with no override.
|
||||
// For OAuth tokens, the plugin auth loader's combined fetch handles
|
||||
// OAuth refresh + snowflake transformations in one place.
|
||||
// For env/config/API-key tokens, the provider fetch applies snowflake
|
||||
// transformations directly.
|
||||
const useOAuthHandler =
|
||||
oauthToken !== undefined && envToken === undefined && apiKeyToken === undefined && configToken === undefined
|
||||
if (!useOAuthHandler) {
|
||||
options.fetch = async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.body && typeof init.body === "string") {
|
||||
try {
|
||||
const body = JSON.parse(init.body)
|
||||
if ("max_tokens" in body) {
|
||||
body.max_completion_tokens = body.max_tokens
|
||||
delete body.max_tokens
|
||||
init = { ...init, body: JSON.stringify(body) }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const response = await fetch(url, init)
|
||||
|
||||
if (!response.ok && response.status === 400) {
|
||||
try {
|
||||
const errorData = await response.clone().json()
|
||||
const errorMessage = String(errorData.message || errorData.error || "")
|
||||
if (errorMessage.toLowerCase().includes("conversation complete")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ finish_reason: "stop", message: { content: "", role: "assistant" } }],
|
||||
}),
|
||||
{ status: 200, headers: new Headers({ "content-type": "application/json" }) },
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (response.body && response.headers.get("content-type")?.includes("text/event-stream")) {
|
||||
const reader = response.body.getReader()
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
const stream = new ReadableStream({
|
||||
async pull(ctrl) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
ctrl.close()
|
||||
return
|
||||
}
|
||||
const text = decoder.decode(value, { stream: true })
|
||||
ctrl.enqueue(encoder.encode(text.replace(/"role"\s*:\s*""/g, '"role":"assistant"')))
|
||||
},
|
||||
cancel() {
|
||||
reader.cancel()
|
||||
},
|
||||
})
|
||||
return new Response(stream, { headers: response.headers, status: response.status })
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
autoload: input.source === "config",
|
||||
options,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1496,7 +1496,7 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7
|
||||
|
||||
if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") {
|
||||
schema = sanitizeOpenAISchema(schema) as JSONSchema7
|
||||
// Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget.
|
||||
// Codex also applies lossy compaction above 4 KB; defer that until Neuron needs the same schema budget.
|
||||
}
|
||||
|
||||
if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { InstanceDisposed } from "@/server/event"
|
||||
import { Question } from "@/question"
|
||||
import { ConfigApi } from "./groups/config"
|
||||
import { ControlApi } from "./groups/control"
|
||||
import { ControlPlaneApi } from "./groups/control-plane"
|
||||
import { EventApi } from "./groups/event"
|
||||
import { ExperimentalApi } from "./groups/experimental"
|
||||
import { FileApi } from "./groups/file"
|
||||
@@ -53,7 +52,6 @@ export const ServerApi = makeApi({
|
||||
|
||||
export const RootHttpApi = HttpApi.make("opencode-root")
|
||||
.addHttpApi(ControlApi)
|
||||
.addHttpApi(ControlPlaneApi)
|
||||
.addHttpApi(GlobalApi)
|
||||
.middleware(SchemaErrorMiddleware)
|
||||
.middleware(Authorization)
|
||||
|
||||
@@ -20,7 +20,7 @@ export const ConfigApi = HttpApi.make("config")
|
||||
OpenApi.annotations({
|
||||
identifier: "config.get",
|
||||
summary: "Get configuration",
|
||||
description: "Retrieve the current OpenCode configuration settings and preferences.",
|
||||
description: "Retrieve the current Neuron configuration settings and preferences.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("update", root, {
|
||||
@@ -32,7 +32,7 @@ export const ConfigApi = HttpApi.make("config")
|
||||
OpenApi.annotations({
|
||||
identifier: "config.update",
|
||||
summary: "Update configuration",
|
||||
description: "Update OpenCode configuration settings and preferences.",
|
||||
description: "Update Neuron configuration settings and preferences.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("providers", `${root}/providers`, {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { described } from "./metadata"
|
||||
|
||||
const root = "/experimental/control-plane"
|
||||
export const MoveSessionPayload = Schema.Struct({ ...MoveSession.Input.fields })
|
||||
|
||||
export class ApiMoveSessionError extends Schema.ErrorClass<ApiMoveSessionError>("MoveSessionError")(
|
||||
{
|
||||
name: Schema.Literal("MoveSessionError"),
|
||||
data: Schema.Struct({
|
||||
message: Schema.String,
|
||||
}),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
export const ControlPlaneApi = HttpApi.make("controlPlane").add(
|
||||
HttpApiGroup.make("controlPlane")
|
||||
.add(
|
||||
HttpApiEndpoint.post("moveSession", `${root}/move-session`, {
|
||||
payload: MoveSessionPayload,
|
||||
success: described(HttpApiSchema.NoContent, "Session moved"),
|
||||
error: ApiMoveSessionError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.controlPlane.moveSession",
|
||||
summary: "Move session",
|
||||
description: "Move a session to another project directory, optionally transferring local changes.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "controlPlane", description: "Control-plane orchestration routes." })),
|
||||
)
|
||||
@@ -112,7 +112,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.capabilities.get",
|
||||
summary: "Get experimental capabilities",
|
||||
description: "Get experimental features enabled on the OpenCode server.",
|
||||
description: "Get experimental features enabled on the Neuron server.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("console", ExperimentalPaths.console, {
|
||||
@@ -146,7 +146,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
OpenApi.annotations({
|
||||
identifier: "experimental.console.switchOrg",
|
||||
summary: "Switch active Console org",
|
||||
description: "Persist a new active Console account/org selection for the current local OpenCode state.",
|
||||
description: "Persist a new active Console account/org selection for the current local Neuron state.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
|
||||
@@ -229,7 +229,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
|
||||
identifier: "experimental.session.list",
|
||||
summary: "List sessions",
|
||||
description:
|
||||
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||
"Get a list of all Neuron sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("sessionBackground", ExperimentalPaths.sessionBackground, {
|
||||
|
||||
@@ -79,7 +79,7 @@ export const GlobalApi = HttpApi.make("global").add(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.health",
|
||||
summary: "Get health",
|
||||
description: "Get health information about the OpenCode server.",
|
||||
description: "Get health information about the Neuron server.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("event", GlobalPaths.event, {
|
||||
@@ -88,7 +88,7 @@ export const GlobalApi = HttpApi.make("global").add(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.event",
|
||||
summary: "Get global events",
|
||||
description: "Subscribe to global events from the OpenCode system using server-sent events.",
|
||||
description: "Subscribe to global events from the Neuron system using server-sent events.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("configGet", GlobalPaths.config, {
|
||||
@@ -97,7 +97,7 @@ export const GlobalApi = HttpApi.make("global").add(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.config.get",
|
||||
summary: "Get global configuration",
|
||||
description: "Retrieve the current global OpenCode configuration settings and preferences.",
|
||||
description: "Retrieve the current global Neuron configuration settings and preferences.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.patch("configUpdate", GlobalPaths.config, {
|
||||
@@ -108,7 +108,7 @@ export const GlobalApi = HttpApi.make("global").add(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.config.update",
|
||||
summary: "Update global configuration",
|
||||
description: "Update global OpenCode configuration settings and preferences.",
|
||||
description: "Update global Neuron configuration settings and preferences.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("dispose", GlobalPaths.dispose, {
|
||||
@@ -117,7 +117,7 @@ export const GlobalApi = HttpApi.make("global").add(
|
||||
OpenApi.annotations({
|
||||
identifier: "global.dispose",
|
||||
summary: "Dispose instance",
|
||||
description: "Clean up and dispose all OpenCode instances, releasing all resources.",
|
||||
description: "Clean up and dispose all Neuron instances, releasing all resources.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
|
||||
|
||||
@@ -66,7 +66,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
OpenApi.annotations({
|
||||
identifier: "instance.dispose",
|
||||
summary: "Dispose instance",
|
||||
description: "Clean up and dispose the current OpenCode instance, releasing all resources.",
|
||||
description: "Clean up and dispose the current Neuron instance, releasing all resources.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("path", InstancePaths.path, {
|
||||
@@ -77,7 +77,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
identifier: "path.get",
|
||||
summary: "Get paths",
|
||||
description:
|
||||
"Retrieve the current working directory and related path information for the OpenCode instance.",
|
||||
"Retrieve the current working directory and related path information for the Neuron instance.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("vcs", InstancePaths.vcs, {
|
||||
@@ -143,7 +143,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
OpenApi.annotations({
|
||||
identifier: "command.list",
|
||||
summary: "List commands",
|
||||
description: "Get a list of all available commands in the OpenCode system.",
|
||||
description: "Get a list of all available commands in the Neuron system.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("agent", InstancePaths.agent, {
|
||||
@@ -153,7 +153,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
OpenApi.annotations({
|
||||
identifier: "app.agents",
|
||||
summary: "List agents",
|
||||
description: "Get a list of all available AI agents in the OpenCode system.",
|
||||
description: "Get a list of all available AI agents in the Neuron system.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("skill", InstancePaths.skill, {
|
||||
@@ -163,7 +163,7 @@ export const InstanceApi = HttpApi.make("instance")
|
||||
OpenApi.annotations({
|
||||
identifier: "app.skills",
|
||||
summary: "List skills",
|
||||
description: "Get a list of all available skills in the OpenCode system.",
|
||||
description: "Get a list of all available skills in the Neuron system.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("lsp", InstancePaths.lsp, {
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ProjectApi = HttpApi.make("project")
|
||||
OpenApi.annotations({
|
||||
identifier: "project.list",
|
||||
summary: "List all projects",
|
||||
description: "Get a list of projects that have been opened with OpenCode.",
|
||||
description: "Get a list of projects that have been opened with Neuron.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("current", `${root}/current`, {
|
||||
@@ -36,7 +36,7 @@ export const ProjectApi = HttpApi.make("project")
|
||||
OpenApi.annotations({
|
||||
identifier: "project.current",
|
||||
summary: "Get current project",
|
||||
description: "Retrieve the currently active project that OpenCode is working with.",
|
||||
description: "Retrieve the currently active project that Neuron is working with.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("initGit", `${root}/git/init`, {
|
||||
|
||||
@@ -58,7 +58,7 @@ export const PtyApi = HttpApi.make("pty")
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.list",
|
||||
summary: "List PTY sessions",
|
||||
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.",
|
||||
description: "Get a list of all active pseudo-terminal (PTY) sessions managed by Neuron.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("create", PtyPaths.create, {
|
||||
|
||||
@@ -115,7 +115,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
OpenApi.annotations({
|
||||
identifier: "session.list",
|
||||
summary: "List sessions",
|
||||
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
|
||||
description: "Get a list of all Neuron sessions, sorted by most recently updated.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("status", SessionPaths.status, {
|
||||
@@ -138,7 +138,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
OpenApi.annotations({
|
||||
identifier: "session.get",
|
||||
summary: "Get session",
|
||||
description: "Retrieve detailed information about a specific OpenCode session.",
|
||||
description: "Retrieve detailed information about a specific Neuron session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("children", SessionPaths.children, {
|
||||
@@ -209,7 +209,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
OpenApi.annotations({
|
||||
identifier: "session.create",
|
||||
summary: "Create session",
|
||||
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
|
||||
description: "Create a new Neuron session for interacting with AI assistants and managing conversations.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { RootHttpApi } from "../api"
|
||||
import { ApiMoveSessionError, MoveSessionPayload } from "../groups/control-plane"
|
||||
|
||||
export const controlPlaneHandlers = HttpApiBuilder.group(RootHttpApi, "controlPlane", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MoveSession.Service
|
||||
|
||||
const moveSession = Effect.fn("ControlPlaneHttpApi.moveSession")(function* (ctx: {
|
||||
payload: typeof MoveSessionPayload.Type
|
||||
}) {
|
||||
yield* service.moveSession(ctx.payload).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ApiMoveSessionError({
|
||||
name: "MoveSessionError",
|
||||
data: { message: message(error) },
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return handlers.handle("moveSession", moveSession)
|
||||
}),
|
||||
)
|
||||
|
||||
function message(error: MoveSession.Error) {
|
||||
if (error instanceof SessionV2.NotFoundError) return `Session not found: ${error.sessionID}`
|
||||
if (error instanceof MoveSession.DestinationProjectMismatchError)
|
||||
return "Destination directory belongs to another project"
|
||||
if (error instanceof MoveSession.ApplyChangesError)
|
||||
return `Unable to apply your changes in the destination directory. The files may conflict with existing changes.`
|
||||
return error.message
|
||||
}
|
||||
@@ -49,7 +49,6 @@ import { ToolRegistry } from "@/tool/registry"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilderV1 } from "@/effect/app-node-builder-v1"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
@@ -84,7 +83,6 @@ import { PtyConnectApi } from "./groups/pty"
|
||||
import { eventHandlers } from "./handlers/event"
|
||||
import { configHandlers } from "./handlers/config"
|
||||
import { controlHandlers } from "./handlers/control"
|
||||
import { controlPlaneHandlers } from "./handlers/control-plane"
|
||||
import { experimentalHandlers } from "./handlers/experimental"
|
||||
import { fileHandlers } from "./handlers/file"
|
||||
import { globalHandlers } from "./handlers/global"
|
||||
@@ -139,7 +137,7 @@ const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provi
|
||||
const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer))
|
||||
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
|
||||
Layer.provide([controlHandlers, globalHandlers]),
|
||||
Layer.provide(schemaErrorLayer),
|
||||
Layer.provide(httpApiAuthLayer),
|
||||
)
|
||||
@@ -288,7 +286,6 @@ export function createRoutes(
|
||||
corsVaryFix,
|
||||
fenceLayer,
|
||||
cors(corsOptions),
|
||||
AppNodeBuilderV1.build(MoveSession.node, [[LocationServiceMap.node, locationServiceMapV2]]),
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
|
||||
|
||||
@@ -7,8 +7,8 @@ import { isRecord } from "@/util/record"
|
||||
|
||||
export type Err = ReturnType<NamedError["toObject"]>
|
||||
|
||||
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go"
|
||||
export const GO_UPSELL_URL = "https://opencode.ai/go"
|
||||
export const GO_UPSELL_MESSAGE = "Free usage exceeded on the managed provider"
|
||||
export const GO_UPSELL_URL = "https://git.neuralplatform.ai/neuron-technologies/neuron"
|
||||
export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {})
|
||||
|
||||
export type Retryable = {
|
||||
@@ -103,7 +103,7 @@ export function retryable(error: Err, provider: string) {
|
||||
reason: "free_tier_limit",
|
||||
provider,
|
||||
title: "Free limit reached",
|
||||
message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
|
||||
message: "Free limit reached on the managed provider. Configure your own provider API key for unlimited use.",
|
||||
label: "subscribe",
|
||||
link: GO_UPSELL_URL,
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("opencode acp initialize/auth subprocess", () => {
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
expect(initialized.agentInfo?.name).toBe("OpenCode")
|
||||
expect(initialized.agentInfo?.name).toBe("Neuron")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode acp --help 1`] = `
|
||||
"opencode acp
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron acp
|
||||
|
||||
start ACP (Agent Client Protocol) server
|
||||
|
||||
@@ -18,31 +23,43 @@ Options:
|
||||
--mdns-domain custom domain name for mDNS service (default: opencode.local)
|
||||
[string] [default: "opencode.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
--cwd working directory [string] [default: "<HOME>"]"
|
||||
--cwd working directory [string] [default: "<HOME>"]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp --help 1`] = `
|
||||
"opencode mcp
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron mcp
|
||||
|
||||
manage MCP (Model Context Protocol) servers
|
||||
|
||||
Commands:
|
||||
opencode mcp add [name] add an MCP server
|
||||
opencode mcp list list MCP servers and their status [aliases: ls]
|
||||
opencode mcp auth [name] authenticate with an OAuth-enabled MCP server
|
||||
opencode mcp logout [name] remove OAuth credentials for an MCP server
|
||||
opencode mcp debug <name> debug OAuth connection for an MCP server
|
||||
neuron mcp add [name] add an MCP server
|
||||
neuron mcp list list MCP servers and their status [aliases: ls]
|
||||
neuron mcp auth [name] authenticate with an OAuth-enabled MCP server
|
||||
neuron mcp logout [name] remove OAuth credentials for an MCP server
|
||||
neuron mcp debug <name> debug OAuth connection for an MCP server
|
||||
|
||||
Options:
|
||||
-h, --help show help [boolean]
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = `
|
||||
"opencode attach <url>
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron attach <url>
|
||||
|
||||
attach to a running opencode server
|
||||
|
||||
@@ -64,11 +81,17 @@ Options:
|
||||
[string]
|
||||
--mini start the minimal interactive interface [boolean] [default: false]
|
||||
--no-replay disable mini session history replay on resume and after resize [boolean]
|
||||
--replay-limit cap visible mini replay to the newest N messages [number]"
|
||||
--replay-limit cap visible mini replay to the newest N messages [number]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = `
|
||||
"opencode run [message..]
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron run [message..]
|
||||
|
||||
run opencode with a message
|
||||
|
||||
@@ -104,74 +127,98 @@ Options:
|
||||
--thinking show thinking blocks [boolean]
|
||||
-i, --interactive run in direct interactive split-footer mode [boolean] [default: false]
|
||||
--auto auto-approve permissions that are not explicitly denied (dangerous!)
|
||||
[boolean] [default: false]"
|
||||
[boolean] [default: false]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode debug --help 1`] = `
|
||||
"opencode debug
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron debug
|
||||
|
||||
debugging and troubleshooting tools
|
||||
|
||||
Commands:
|
||||
opencode debug config show resolved configuration
|
||||
opencode debug lsp LSP debugging utilities
|
||||
opencode debug rg ripgrep debugging utilities
|
||||
opencode debug file file system debugging utilities
|
||||
opencode debug scrap list all known projects
|
||||
opencode debug skill list all available skills
|
||||
opencode debug snapshot snapshot debugging utilities
|
||||
opencode debug startup print startup timing
|
||||
opencode debug agent <name> show agent configuration details
|
||||
opencode debug v2 debug v2 catalog and built-in plugins
|
||||
opencode debug info show debug information
|
||||
opencode debug paths show global paths (data, config, cache, state)
|
||||
opencode debug wait wait indefinitely (for debugging)
|
||||
neuron debug config show resolved configuration
|
||||
neuron debug lsp LSP debugging utilities
|
||||
neuron debug rg ripgrep debugging utilities
|
||||
neuron debug file file system debugging utilities
|
||||
neuron debug scrap list all known projects
|
||||
neuron debug skill list all available skills
|
||||
neuron debug snapshot snapshot debugging utilities
|
||||
neuron debug startup print startup timing
|
||||
neuron debug agent <name> show agent configuration details
|
||||
neuron debug v2 debug v2 catalog and built-in plugins
|
||||
neuron debug info show debug information
|
||||
neuron debug paths show global paths (data, config, cache, state)
|
||||
neuron debug wait wait indefinitely (for debugging)
|
||||
|
||||
Options:
|
||||
-h, --help show help [boolean]
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers --help 1`] = `
|
||||
"opencode providers
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron providers
|
||||
|
||||
manage AI providers and credentials
|
||||
|
||||
Commands:
|
||||
opencode providers list list providers and credentials [aliases: ls]
|
||||
opencode providers login [url] log in to a provider
|
||||
opencode providers logout [provider] log out from a configured provider
|
||||
neuron providers list list providers and credentials [aliases: ls]
|
||||
neuron providers login [url] log in to a provider
|
||||
neuron providers logout [provider] log out from a configured provider
|
||||
|
||||
Options:
|
||||
-h, --help show help [boolean]
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent --help 1`] = `
|
||||
"opencode agent
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron agent
|
||||
|
||||
manage agents
|
||||
|
||||
Commands:
|
||||
opencode agent create create a new agent
|
||||
opencode agent list list all available agents
|
||||
neuron agent create create a new agent
|
||||
neuron agent list list all available agents
|
||||
|
||||
Options:
|
||||
-h, --help show help [boolean]
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode upgrade --help 1`] = `
|
||||
"opencode upgrade [target]
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron upgrade [target]
|
||||
|
||||
upgrade opencode to the latest or a specific version
|
||||
|
||||
@@ -185,11 +232,17 @@ Options:
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]
|
||||
-m, --method installation method to use
|
||||
[string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]"
|
||||
[string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode uninstall --help 1`] = `
|
||||
"opencode uninstall
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron uninstall
|
||||
|
||||
uninstall opencode and remove all related files
|
||||
|
||||
@@ -202,11 +255,17 @@ Options:
|
||||
-c, --keep-config keep configuration files [boolean] [default: false]
|
||||
-d, --keep-data keep session data and snapshots [boolean] [default: false]
|
||||
--dry-run show what would be removed without removing [boolean] [default: false]
|
||||
-f, --force skip confirmation prompts [boolean] [default: false]"
|
||||
-f, --force skip confirmation prompts [boolean] [default: false]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode serve --help 1`] = `
|
||||
"opencode serve
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron serve
|
||||
|
||||
starts a headless opencode server
|
||||
|
||||
@@ -222,11 +281,17 @@ Options:
|
||||
[boolean] [default: false]
|
||||
--mdns-domain custom domain name for mDNS service (default: opencode.local)
|
||||
[string] [default: "opencode.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]"
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode web --help 1`] = `
|
||||
"opencode web
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron web
|
||||
|
||||
start opencode server and open web interface
|
||||
|
||||
@@ -242,11 +307,17 @@ Options:
|
||||
[boolean] [default: false]
|
||||
--mdns-domain custom domain name for mDNS service (default: opencode.local)
|
||||
[string] [default: "opencode.local"]
|
||||
--cors additional domains to allow for CORS [array] [default: []]"
|
||||
--cors additional domains to allow for CORS [array] [default: []]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode models --help 1`] = `
|
||||
"opencode models [provider]
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron models [provider]
|
||||
|
||||
list all available models
|
||||
|
||||
@@ -260,11 +331,17 @@ Options:
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]
|
||||
--verbose use more verbose model output (includes metadata like costs) [boolean]
|
||||
--refresh refresh the models cache from models.dev [boolean]"
|
||||
--refresh refresh the models cache from models.dev [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode stats --help 1`] = `
|
||||
"opencode stats
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron stats
|
||||
|
||||
show token usage and cost statistics
|
||||
|
||||
@@ -278,11 +355,17 @@ Options:
|
||||
--tools number of tools to show (default: all) [number]
|
||||
--models show model statistics (default: hidden). Pass a number to show top N, otherwise
|
||||
shows all
|
||||
--project filter by project (default: all projects, empty string: current project)[string]"
|
||||
--project filter by project (default: all projects, empty string: current project)[string]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode export --help 1`] = `
|
||||
"opencode export [sessionID]
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron export [sessionID]
|
||||
|
||||
export session data as JSON
|
||||
|
||||
@@ -295,11 +378,17 @@ Options:
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]
|
||||
--sanitize redact sensitive transcript and file data [boolean]"
|
||||
--sanitize redact sensitive transcript and file data [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode import --help 1`] = `
|
||||
"opencode import <file>
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron import <file>
|
||||
|
||||
import session data from JSON file or URL
|
||||
|
||||
@@ -311,11 +400,17 @@ Options:
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode pr --help 1`] = `
|
||||
"opencode pr <number>
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron pr <number>
|
||||
|
||||
fetch and checkout a GitHub PR branch, then run opencode
|
||||
|
||||
@@ -327,28 +422,40 @@ Options:
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session --help 1`] = `
|
||||
"opencode session
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron session
|
||||
|
||||
manage sessions
|
||||
|
||||
Commands:
|
||||
opencode session list list sessions
|
||||
opencode session delete <sessionID> delete a session
|
||||
neuron session list list sessions
|
||||
neuron session delete <sessionID> delete a session
|
||||
|
||||
Options:
|
||||
-h, --help show help [boolean]
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode plugin --help 1`] = `
|
||||
"opencode plugin <module>
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron plugin <module>
|
||||
|
||||
install plugin and update config
|
||||
|
||||
@@ -362,17 +469,23 @@ Options:
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]
|
||||
-g, --global install in global config [boolean] [default: false]
|
||||
-f, --force replace existing plugin version [boolean] [default: false]"
|
||||
-f, --force replace existing plugin version [boolean] [default: false]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode db --help 1`] = `
|
||||
"opencode db
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron db
|
||||
|
||||
database tools
|
||||
|
||||
Commands:
|
||||
opencode db [query] open an interactive sqlite3 shell or run a query [default]
|
||||
opencode db path print the database path
|
||||
neuron db [query] open an interactive sqlite3 shell or run a query [default]
|
||||
neuron db path print the database path
|
||||
|
||||
Positionals:
|
||||
query SQL query to execute [string]
|
||||
@@ -383,11 +496,17 @@ Options:
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]
|
||||
--format Output format [string] [choices: "json", "tsv"] [default: "tsv"]"
|
||||
--format Output format [string] [choices: "json", "tsv"] [default: "tsv"]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = `
|
||||
"opencode mcp list
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron mcp list
|
||||
|
||||
list MCP servers and their status
|
||||
|
||||
@@ -396,11 +515,17 @@ Options:
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp add --help 1`] = `
|
||||
"opencode mcp add [name]
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron mcp add [name]
|
||||
|
||||
add an MCP server
|
||||
|
||||
@@ -415,16 +540,22 @@ Options:
|
||||
--pure run without external plugins [boolean]
|
||||
--url URL for a remote MCP server [string]
|
||||
--env environment variable for a local MCP server (KEY=VALUE) [array]
|
||||
--header HTTP header for a remote MCP server (KEY=VALUE) [array]"
|
||||
--header HTTP header for a remote MCP server (KEY=VALUE) [array]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp auth --help 1`] = `
|
||||
"opencode mcp auth [name]
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron mcp auth [name]
|
||||
|
||||
authenticate with an OAuth-enabled MCP server
|
||||
|
||||
Commands:
|
||||
opencode mcp auth list list OAuth-capable MCP servers and their auth status [aliases: ls]
|
||||
neuron mcp auth list list OAuth-capable MCP servers and their auth status [aliases: ls]
|
||||
|
||||
Positionals:
|
||||
name name of the MCP server [string]
|
||||
@@ -434,11 +565,17 @@ Options:
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp logout --help 1`] = `
|
||||
"opencode mcp logout [name]
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron mcp logout [name]
|
||||
|
||||
remove OAuth credentials for an MCP server
|
||||
|
||||
@@ -450,11 +587,17 @@ Options:
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers list --help 1`] = `
|
||||
"opencode providers list
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron providers list
|
||||
|
||||
list providers and credentials
|
||||
|
||||
@@ -463,11 +606,17 @@ Options:
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers login --help 1`] = `
|
||||
"opencode providers login [url]
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron providers login [url]
|
||||
|
||||
log in to a provider
|
||||
|
||||
@@ -481,11 +630,17 @@ Options:
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]
|
||||
-p, --provider provider id or name to log in to (skips provider selection) [string]
|
||||
-m, --method login method label (skips method selection) [string]"
|
||||
-m, --method login method label (skips method selection) [string]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode providers logout --help 1`] = `
|
||||
"opencode providers logout [provider]
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron providers logout [provider]
|
||||
|
||||
log out from a configured provider
|
||||
|
||||
@@ -497,11 +652,17 @@ Options:
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent create --help 1`] = `
|
||||
"opencode agent create
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron agent create
|
||||
|
||||
create a new agent
|
||||
|
||||
@@ -517,11 +678,17 @@ Options:
|
||||
--permissions, --tools comma-separated list of permissions to allow (default: all).
|
||||
Available: "bash, read, edit, glob, grep, webfetch, task, todowrite,
|
||||
websearch, lsp, skill" [string]
|
||||
-m, --model model to use in the format of provider/model [string]"
|
||||
-m, --model model to use in the format of provider/model [string]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent list --help 1`] = `
|
||||
"opencode agent list
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron agent list
|
||||
|
||||
list all available agents
|
||||
|
||||
@@ -530,11 +697,17 @@ Options:
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session list --help 1`] = `
|
||||
"opencode session list
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron session list
|
||||
|
||||
list sessions
|
||||
|
||||
@@ -545,11 +718,17 @@ Options:
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]
|
||||
-n, --max-count limit to N most recent sessions [number]
|
||||
--format output format [string] [choices: "table", "json"] [default: "table"]"
|
||||
--format output format [string] [choices: "table", "json"] [default: "table"]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode session delete --help 1`] = `
|
||||
"opencode session delete <sessionID>
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron session delete <sessionID>
|
||||
|
||||
delete a session
|
||||
|
||||
@@ -561,11 +740,17 @@ Options:
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode db path --help 1`] = `
|
||||
"opencode db path
|
||||
"⠀ ▄
|
||||
█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█
|
||||
█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀
|
||||
▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀
|
||||
|
||||
neuron db path
|
||||
|
||||
print the database path
|
||||
|
||||
@@ -574,5 +759,6 @@ Options:
|
||||
-v, --version show version number [boolean]
|
||||
--print-logs print logs to stderr [boolean]
|
||||
--log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"]
|
||||
--pure run without external plugins [boolean]"
|
||||
--pure run without external plugins [boolean]
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -132,11 +132,11 @@ describe("run permission shared", () => {
|
||||
|
||||
test("formats always-allow copy for wildcard and explicit patterns", () => {
|
||||
expect(permissionAlwaysLines(req({ permission: "bash", always: ["*"] }))).toEqual([
|
||||
"This will allow bash until OpenCode is restarted.",
|
||||
"This will allow bash until Neuron is restarted.",
|
||||
])
|
||||
|
||||
expect(permissionAlwaysLines(req({ always: ["src/**/*.ts", "src/**/*.tsx"] }))).toEqual([
|
||||
"This will allow the following patterns until OpenCode is restarted.",
|
||||
"This will allow the following patterns until Neuron is restarted.",
|
||||
"- src/**/*.ts",
|
||||
"- src/**/*.tsx",
|
||||
])
|
||||
|
||||
@@ -164,22 +164,7 @@ describe("installation", () => {
|
||||
const brewInfoJson = JSON.stringify({
|
||||
formulae: [{ versions: { stable: "2.1.0" } }],
|
||||
})
|
||||
testEffect(
|
||||
testLayer(
|
||||
() => jsonResponse({}), // HTTP not used for tap formula
|
||||
(cmd, args) => {
|
||||
if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) return "opencode"
|
||||
if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson
|
||||
return ""
|
||||
},
|
||||
),
|
||||
).effect("reads brew tap info JSON via CLI", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* Installation.use.latest("brew")
|
||||
expect(result).toBe("2.1.0")
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("upgrade", () => {
|
||||
testEffect(
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { CloudflareAIGatewayAuthPlugin } from "@/plugin/cloudflare"
|
||||
|
||||
const pluginInput = {
|
||||
client: {} as never,
|
||||
project: {} as never,
|
||||
directory: "",
|
||||
worktree: "",
|
||||
experimental_workspace: {
|
||||
register() {},
|
||||
},
|
||||
serverUrl: new URL("https://example.com"),
|
||||
$: {} as never,
|
||||
}
|
||||
|
||||
test("registers the cloudflare-ai-gateway auth method", async () => {
|
||||
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
|
||||
expect(hooks.auth?.provider).toBe("cloudflare-ai-gateway")
|
||||
expect(hooks.auth?.methods).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("no longer drops maxOutputTokens; OpenAI models ride the Responses API passthrough", async () => {
|
||||
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
|
||||
expect(hooks["chat.params"]).toBeUndefined()
|
||||
})
|
||||
@@ -1,198 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Model, Provider } from "@opencode-ai/sdk/v2"
|
||||
import { ModalPlugin } from "@/plugin/modal/modal"
|
||||
|
||||
const BASE_MODEL_ID = "thinkingmachines/Inkling-NVFP4"
|
||||
const RUNTIME_MODEL_ID = "workspace--inkling.us-west.modal.direct"
|
||||
const FALLBACK_RUNTIME_MODEL_ID = "workspace--inkling-fallback.us-west.modal.direct"
|
||||
|
||||
function makeProvider(baseURL: string): Provider {
|
||||
const template: Model = {
|
||||
id: BASE_MODEL_ID,
|
||||
providerID: "modal",
|
||||
name: "Inkling",
|
||||
family: "ling",
|
||||
api: {
|
||||
id: BASE_MODEL_ID,
|
||||
url: baseURL,
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
status: "active",
|
||||
headers: {},
|
||||
options: {},
|
||||
cost: {
|
||||
input: 1,
|
||||
output: 4,
|
||||
cache: {
|
||||
read: 0.2,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
context: 128_000,
|
||||
output: 8_192,
|
||||
},
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: {
|
||||
text: true,
|
||||
audio: true,
|
||||
image: true,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
output: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
interleaved: {
|
||||
field: "reasoning_content",
|
||||
},
|
||||
},
|
||||
release_date: "2026-07-15",
|
||||
variants: {
|
||||
fallback: {
|
||||
reasoningEffort: "fallback",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
id: "modal",
|
||||
name: "Modal",
|
||||
source: "api",
|
||||
env: ["MODAL_PROXY_TOKEN"],
|
||||
options: {},
|
||||
models: {
|
||||
[template.id]: template,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test("discovers Modal workspace models", async () => {
|
||||
const requests: Array<{ authorization: string | null; path: string }> = []
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
requests.push({
|
||||
authorization: request.headers.get("authorization"),
|
||||
path: new URL(request.url).pathname,
|
||||
})
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: RUNTIME_MODEL_ID,
|
||||
base_model_id: BASE_MODEL_ID,
|
||||
name: "Thinking Machines: Inkling",
|
||||
input_modalities: ["text", "image", "audio"],
|
||||
output_modalities: ["text"],
|
||||
context_length: 1_048_576,
|
||||
max_output_length: 262_144,
|
||||
pricing: {
|
||||
prompt: "0.0000012",
|
||||
completion: "0.000005",
|
||||
input_cache_read: "0.00000027",
|
||||
},
|
||||
supported_sampling_parameters: ["temperature"],
|
||||
supported_features: ["tools", "reasoning"],
|
||||
reasoning_options: [
|
||||
{
|
||||
type: "effort",
|
||||
values: ["none", "low", "medium", "high", "xhigh", "max"],
|
||||
},
|
||||
],
|
||||
interleaved: {
|
||||
field: "reasoning_content",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: FALLBACK_RUNTIME_MODEL_ID,
|
||||
base_model_id: BASE_MODEL_ID,
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
const provider = makeProvider(`${server.url}v1`)
|
||||
|
||||
const plugin = await ModalPlugin()
|
||||
const models = await plugin.provider!.models!(provider, {
|
||||
auth: {
|
||||
type: "api",
|
||||
key: "test-token",
|
||||
},
|
||||
})
|
||||
const model = models[RUNTIME_MODEL_ID]
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
authorization: "Bearer test-token",
|
||||
path: "/v1/models",
|
||||
},
|
||||
])
|
||||
expect(Object.keys(models)).toEqual([RUNTIME_MODEL_ID, FALLBACK_RUNTIME_MODEL_ID])
|
||||
expect(model.api).toEqual({
|
||||
id: RUNTIME_MODEL_ID,
|
||||
url: `${server.url}v1`,
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
})
|
||||
expect(model.family).toBe("ling")
|
||||
expect(model.capabilities.interleaved).toEqual({ field: "reasoning_content" })
|
||||
expect(model.capabilities.input).toEqual({
|
||||
text: true,
|
||||
audio: true,
|
||||
image: true,
|
||||
video: false,
|
||||
pdf: false,
|
||||
})
|
||||
expect(model.cost).toEqual({
|
||||
input: 1.2,
|
||||
output: 5,
|
||||
cache: {
|
||||
read: 0.27,
|
||||
write: 0,
|
||||
},
|
||||
})
|
||||
expect(model.limit).toEqual({
|
||||
context: 1_048_576,
|
||||
output: 262_144,
|
||||
})
|
||||
expect(model.variants).toEqual({
|
||||
none: { reasoningEffort: "none" },
|
||||
low: { reasoningEffort: "low" },
|
||||
medium: { reasoningEffort: "medium" },
|
||||
high: { reasoningEffort: "high" },
|
||||
xhigh: { reasoningEffort: "xhigh" },
|
||||
max: { reasoningEffort: "max" },
|
||||
})
|
||||
expect(models[FALLBACK_RUNTIME_MODEL_ID].variants).toEqual({
|
||||
fallback: {
|
||||
reasoningEffort: "fallback",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("hides Modal models when discovery fails", async () => {
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return new Response(null, { status: 503 })
|
||||
},
|
||||
})
|
||||
|
||||
const plugin = await ModalPlugin()
|
||||
const models = await plugin.provider!.models!(makeProvider(`${server.url}v1`), {
|
||||
auth: {
|
||||
type: "api",
|
||||
key: "test-token",
|
||||
},
|
||||
})
|
||||
|
||||
expect(models).toEqual({})
|
||||
})
|
||||
@@ -1,278 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OAUTH_DUMMY_KEY } from "../../src/auth"
|
||||
import { oauthScope, SnowflakeCortexAuthPlugin } from "../../src/plugin/snowflake-cortex"
|
||||
|
||||
function makeInput() {
|
||||
let auth: any = {
|
||||
type: "oauth",
|
||||
access: "access-old",
|
||||
refresh: "refresh-old",
|
||||
expires: Date.now() + 3600_000,
|
||||
accountId: "myorg-myaccount",
|
||||
}
|
||||
const setCalls: Array<Record<string, unknown>> = []
|
||||
|
||||
return {
|
||||
getAuth: async () => auth,
|
||||
setAuth: (next: any) => {
|
||||
auth = next
|
||||
},
|
||||
input: {
|
||||
client: {
|
||||
auth: {
|
||||
set: async (request: any) => {
|
||||
setCalls.push(request)
|
||||
auth = request.body
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
setCalls,
|
||||
}
|
||||
}
|
||||
|
||||
describe("plugin.snowflake-cortex", () => {
|
||||
test("oauthScope uses Snowflake-compatible scope values", () => {
|
||||
expect(oauthScope(undefined)).toBe("refresh_token")
|
||||
expect(oauthScope("PUBLIC")).toBe("refresh_token session:role:PUBLIC")
|
||||
expect(oauthScope("AUTH SNOWFLAKE")).toBe("refresh_token session:role-encoded:AUTH%20SNOWFLAKE")
|
||||
})
|
||||
|
||||
test("loader returns empty options when auth is not oauth", async () => {
|
||||
const hooks = await SnowflakeCortexAuthPlugin({} as any)
|
||||
const options = await hooks.auth!.loader!(async () => ({ type: "api", key: "token" }) as any, {} as any)
|
||||
expect(options).toEqual({})
|
||||
})
|
||||
|
||||
test("loader injects bearer header and preserves custom headers", async () => {
|
||||
const { input, getAuth, setAuth } = makeInput()
|
||||
setAuth({
|
||||
type: "oauth",
|
||||
access: "access-live",
|
||||
refresh: "refresh-live",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
accountId: "myorg-myaccount",
|
||||
})
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
expect(options.apiKey).toBe(OAUTH_DUMMY_KEY)
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const captured: Headers[] = []
|
||||
globalThis.fetch = (async (_request, init) => {
|
||||
captured.push(new Headers(init?.headers))
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await options.fetch("https://example.test/v1/chat", {
|
||||
headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-keep": "yes" },
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(captured).toHaveLength(1)
|
||||
expect(captured[0].get("authorization")).toBe("Bearer access-live")
|
||||
expect(captured[0].get("x-keep")).toBe("yes")
|
||||
expect(captured[0].get("user-agent")).toMatch(/^opencode\//)
|
||||
})
|
||||
|
||||
test("loader refreshes expired token with single-flight and persists refreshed oauth", async () => {
|
||||
const { input, getAuth, setCalls } = makeInput()
|
||||
let refreshCalls = 0
|
||||
const apiAuthHeaders: string[] = []
|
||||
|
||||
// Must mock fetch before calling loader because startup refresh triggers for expires: 0
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (request, init) => {
|
||||
const url =
|
||||
typeof request === "string" ? request : request instanceof URL ? request.toString() : String(request.url)
|
||||
|
||||
if (url.includes("/oauth/token-request")) {
|
||||
refreshCalls += 1
|
||||
const body = new URLSearchParams(String(init?.body ?? ""))
|
||||
expect(body.get("grant_type")).toBe("refresh_token")
|
||||
expect(body.get("refresh_token")).toBe("refresh-old")
|
||||
expect(new Headers(init?.headers).get("authorization")).toMatch(/^Basic /)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
return Response.json({ access_token: "access-new", refresh_token: "refresh-new", expires_in: 3600 })
|
||||
}
|
||||
|
||||
apiAuthHeaders.push(new Headers(init?.headers).get("authorization") || "")
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(
|
||||
async () =>
|
||||
({
|
||||
type: "oauth",
|
||||
access: "access-expired",
|
||||
refresh: "refresh-old",
|
||||
expires: 0,
|
||||
accountId: "myorg-myaccount",
|
||||
}) as any,
|
||||
{} as any,
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
options.fetch("https://example.test/v1/chat", { headers: {} }),
|
||||
options.fetch("https://example.test/v1/chat", { headers: {} }),
|
||||
])
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(refreshCalls).toBe(1)
|
||||
expect(apiAuthHeaders).toEqual(["Bearer access-new", "Bearer access-new"])
|
||||
expect(setCalls).toHaveLength(1)
|
||||
expect((setCalls[0] as any).body).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "access-new",
|
||||
refresh: "refresh-new",
|
||||
accountId: "myorg-myaccount",
|
||||
})
|
||||
})
|
||||
|
||||
test("loader retries once after 401 by refreshing token", async () => {
|
||||
const { input, getAuth, setCalls } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(
|
||||
async () =>
|
||||
({
|
||||
type: "oauth",
|
||||
access: "access-stale",
|
||||
refresh: "refresh-old",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
accountId: "myorg-myaccount",
|
||||
}) as any,
|
||||
{} as any,
|
||||
)
|
||||
|
||||
let apiCalls = 0
|
||||
const seenAuth: string[] = []
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (request, init) => {
|
||||
const url =
|
||||
typeof request === "string" ? request : request instanceof URL ? request.toString() : String(request.url)
|
||||
|
||||
if (url.includes("/oauth/token-request")) {
|
||||
return Response.json({ access_token: "access-fresh", refresh_token: "refresh-fresh", expires_in: 3600 })
|
||||
}
|
||||
|
||||
apiCalls += 1
|
||||
seenAuth.push(new Headers(init?.headers).get("authorization") || "")
|
||||
if (apiCalls === 1) return new Response("unauthorized", { status: 401 })
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const response = await options.fetch("https://example.test/v1/chat", { headers: {} })
|
||||
expect(response.status).toBe(200)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(apiCalls).toBe(2)
|
||||
expect(seenAuth).toEqual(["Bearer access-stale", "Bearer access-fresh"])
|
||||
expect(setCalls).toHaveLength(1)
|
||||
expect((setCalls[0] as any).body).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "access-fresh",
|
||||
refresh: "refresh-fresh",
|
||||
accountId: "myorg-myaccount",
|
||||
})
|
||||
})
|
||||
|
||||
test("loader converts max_tokens to max_completion_tokens in request body", async () => {
|
||||
const { input, getAuth } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
|
||||
let sentBody: string | undefined
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (request, init) => {
|
||||
sentBody = typeof init?.body === "string" ? init.body : undefined
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await options.fetch("https://example.test/v1/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 4096, messages: [] }),
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
expect(sentBody).toBeDefined()
|
||||
const parsed = JSON.parse(sentBody!)
|
||||
expect(parsed.max_completion_tokens).toBe(4096)
|
||||
expect(parsed.max_tokens).toBeUndefined()
|
||||
expect(parsed.model).toBe("claude-sonnet-4-5")
|
||||
})
|
||||
|
||||
test("loader maps 400 'conversation complete' to 200 stop", async () => {
|
||||
const { input, getAuth } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = (async () => {
|
||||
return new Response(JSON.stringify({ message: "Conversation complete" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
const response = await options.fetch("https://example.test/v1/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "test", messages: [] }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json()
|
||||
expect(body.choices[0].finish_reason).toBe("stop")
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("loader fixes empty role in SSE stream", async () => {
|
||||
const { input, getAuth } = makeInput()
|
||||
const hooks = await SnowflakeCortexAuthPlugin(input)
|
||||
const options = await hooks.auth!.loader!(getAuth as any, {} as any)
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const sseChunk = `data: {"choices":[{"delta":{"role":"","content":"hello"}}]}\n\n`
|
||||
globalThis.fetch = (async () => {
|
||||
const stream = new ReadableStream({
|
||||
start(ctrl) {
|
||||
ctrl.enqueue(new TextEncoder().encode(sseChunk))
|
||||
ctrl.close()
|
||||
},
|
||||
})
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
try {
|
||||
const response = await options.fetch("https://example.test/v1/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "test", messages: [], stream: true }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const reader = response.body!.getReader()
|
||||
const { value } = await reader.read()
|
||||
const text = new TextDecoder().decode(value)
|
||||
expect(text).not.toContain('"role":""')
|
||||
expect(text).toContain('"role":"assistant"')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,361 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { unlink } from "fs/promises"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
||||
import { disposeAllInstances } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node])))
|
||||
|
||||
const originalEnv = new Map<string, string | undefined>()
|
||||
|
||||
const set = (k: string, v: string) =>
|
||||
Effect.gen(function* () {
|
||||
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
|
||||
process.env[k] = v
|
||||
yield* Env.use.set(k, v)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [key, value] of originalEnv) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
originalEnv.clear()
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const list = Provider.use.list()
|
||||
|
||||
const mantleModelConfig = {
|
||||
provider: { npm: "@ai-sdk/amazon-bedrock/mantle" },
|
||||
limit: { context: 272_000, output: 32_000 },
|
||||
modalities: {
|
||||
input: ["text", "image", "pdf"] as Array<"text" | "image" | "pdf">,
|
||||
output: ["text"] as Array<"text">,
|
||||
},
|
||||
}
|
||||
|
||||
const withAuthJson = (contents: string) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
let original: string | undefined
|
||||
try {
|
||||
original = await Filesystem.readText(authPath)
|
||||
} catch {
|
||||
original = undefined
|
||||
}
|
||||
await Filesystem.write(authPath, contents)
|
||||
return { authPath, original }
|
||||
}),
|
||||
({ authPath, original }) =>
|
||||
Effect.promise(async () => {
|
||||
if (original !== undefined) {
|
||||
await Filesystem.write(authPath, original)
|
||||
return
|
||||
}
|
||||
await unlink(authPath).catch(() => undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: config region takes precedence over AWS_REGION env var",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_REGION", "us-east-1")
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
}),
|
||||
{ config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
|
||||
)
|
||||
|
||||
it.instance("Bedrock: falls back to AWS_REGION env var when no config region", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_REGION", "eu-west-1")
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: loads when bearer token from auth.json is present",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withAuthJson(JSON.stringify({ "amazon-bedrock": { type: "api", key: "test-bearer-token" } }))
|
||||
yield* set("AWS_PROFILE", "")
|
||||
yield* set("AWS_ACCESS_KEY_ID", "")
|
||||
yield* set("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
}),
|
||||
{ config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock Mantle: GPT-5.5 uses Responses API and OpenAI base path",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_REGION", "")
|
||||
yield* set("AWS_PROFILE", "")
|
||||
yield* set("AWS_ACCESS_KEY_ID", "")
|
||||
yield* set("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||
const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5"))
|
||||
const language = yield* Provider.use.getLanguage(model)
|
||||
expect((language as { provider: string }).provider).toBe("bedrock-mantle.responses")
|
||||
expect((language as { modelId: string }).modelId).toBe("openai.gpt-5.5")
|
||||
expect(
|
||||
(language as unknown as { config: { url: (input: { path: string; modelId: string }) => string } }).config.url({
|
||||
path: "/responses",
|
||||
modelId: "openai.gpt-5.5",
|
||||
}),
|
||||
).toBe("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-2", apiKey: "test-bearer-token" },
|
||||
models: {
|
||||
"openai.gpt-5.5": {
|
||||
...mantleModelConfig,
|
||||
provider: {
|
||||
npm: "@ai-sdk/amazon-bedrock/mantle",
|
||||
api: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock Mantle: GPT OSS safeguard uses Chat Completions and Mantle base path",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token")
|
||||
const model = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.amazonBedrock,
|
||||
ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
|
||||
)
|
||||
const language = yield* Provider.use.getLanguage(model)
|
||||
expect((language as { provider: string }).provider).toBe("bedrock-mantle.chat")
|
||||
expect((language as { modelId: string }).modelId).toBe("openai.gpt-oss-safeguard-120b")
|
||||
expect(
|
||||
(language as unknown as { config: { url: (input: { path: string; modelId: string }) => string } }).config.url({
|
||||
path: "/chat/completions",
|
||||
modelId: "openai.gpt-oss-safeguard-120b",
|
||||
}),
|
||||
).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-1" },
|
||||
models: { "openai.gpt-oss-safeguard-120b": mantleModelConfig },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: config profile takes precedence over AWS_PROFILE env var",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
yield* set("AWS_ACCESS_KEY_ID", "test-key-id")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: { "amazon-bedrock": { options: { profile: "my-custom-profile", region: "us-east-1" } } },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: includes custom endpoint in options when specified",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.endpoint).toBe(
|
||||
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
|
||||
)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
|
||||
yield* set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
|
||||
yield* set("AWS_PROFILE", "")
|
||||
yield* set("AWS_ACCESS_KEY_ID", "")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1")
|
||||
}),
|
||||
{ config: { provider: { "amazon-bedrock": { options: { region: "us-east-1" } } } } },
|
||||
)
|
||||
|
||||
// Cross-region inference profile prefix handling.
|
||||
// Models from models.dev may come with prefixes already (e.g. us., eu., global.).
|
||||
// These should NOT be double-prefixed when passed to the SDK.
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model with us. prefix should not be double-prefixed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-1" },
|
||||
models: { "us.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (US)" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model with global. prefix should not be prefixed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(
|
||||
providers[ProviderV2.ID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"],
|
||||
).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-1" },
|
||||
models: { "global.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (Global)" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model with eu. prefix should not be double-prefixed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "eu-west-1" },
|
||||
models: { "eu.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (EU)" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model without prefix in US region should get us. prefix added",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-1" },
|
||||
models: { "anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Direct unit tests for cross-region inference profile prefix detection.
|
||||
describe("Bedrock cross-region prefix detection", () => {
|
||||
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
|
||||
|
||||
test("should detect global. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "global.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect us. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "us.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect eu. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "eu.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect jp. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "jp.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect apac. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "apac.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect au. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "au.anthropic.claude-sonnet-4-5-20250929-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for non-prefixed model", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(false)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for amazon nova models", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "amazon.nova-pro-v1:0".startsWith(p))).toBe(false)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for cohere models", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "cohere.command-r-plus-v1:0".startsWith(p))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -713,14 +713,6 @@ it.instance("getSmallModel returns appropriate small model", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("getSmallModel prefers Gemini for Google Vertex", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("GOOGLE_VERTEX_PROJECT", "test-project")
|
||||
const model = yield* Provider.use.getSmallModel(ProviderV2.ID.googleVertex)
|
||||
expect(model).toBeDefined()
|
||||
expect(model?.id).toContain("gemini")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"getSmallModel selects the latest model in the preferred family",
|
||||
@@ -1282,7 +1274,7 @@ it.instance(
|
||||
expect(providers[ProviderV2.ID.make("nvidia")].options.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "Neuron",
|
||||
})
|
||||
}),
|
||||
{ config: { provider: { nvidia: { options: { apiKey: "test-api-key" } } } } },
|
||||
@@ -1295,7 +1287,7 @@ it.instance(
|
||||
expect(providers[ProviderV2.ID.make("nvidia")].options.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
|
||||
"X-BILLING-INVOKE-ORIGIN": "Neuron",
|
||||
})
|
||||
}),
|
||||
{ config: { provider: { nvidia: { options: { apiKey: "test-api-key", baseURL: "http://localhost:8000/v1" } } } } },
|
||||
@@ -1804,136 +1796,12 @@ it.instance(
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Google Vertex: retains baseURL for custom proxy",
|
||||
Effect.gen(function* () {
|
||||
yield* set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.make("vertex-proxy")]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"vertex-proxy": {
|
||||
name: "Vertex Proxy",
|
||||
npm: "@ai-sdk/google-vertex",
|
||||
api: "https://my-proxy.com/v1",
|
||||
env: ["GOOGLE_APPLICATION_CREDENTIALS"],
|
||||
models: { "gemini-pro": { name: "Gemini Pro", tool_call: true } },
|
||||
options: {
|
||||
project: "test-project",
|
||||
location: "us-central1",
|
||||
baseURL: "https://my-proxy.com/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Google Vertex: supports OpenAI compatible models",
|
||||
Effect.gen(function* () {
|
||||
yield* set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds")
|
||||
const providers = yield* list
|
||||
const model = providers[ProviderV2.ID.make("vertex-openai")].models["gpt-4"]
|
||||
expect(model).toBeDefined()
|
||||
expect(model.api.npm).toBe("@ai-sdk/openai-compatible")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"vertex-openai": {
|
||||
name: "Vertex OpenAI",
|
||||
npm: "@ai-sdk/google-vertex",
|
||||
env: ["GOOGLE_APPLICATION_CREDENTIALS"],
|
||||
models: {
|
||||
"gpt-4": {
|
||||
name: "GPT-4",
|
||||
provider: { npm: "@ai-sdk/openai-compatible", api: "https://api.openai.com/v1" },
|
||||
},
|
||||
},
|
||||
options: { project: "test-project", location: "us-central1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance("Google Vertex: uses REP endpoint for Claude continental multi-regions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
|
||||
yield* set("VERTEX_LOCATION", "eu")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(
|
||||
ProviderV2.ID.make("google-vertex"),
|
||||
ModelV2.ID.make("claude-sonnet-4-6@default"),
|
||||
)
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(languageBaseURL(language)).toBe(
|
||||
"https://aiplatform.eu.rep.googleapis.com/v1/projects/test-project/locations/eu/publishers/anthropic/models",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("Google Vertex Anthropic: uses REP endpoint for continental multi-regions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
|
||||
yield* set("VERTEX_LOCATION", "us")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(
|
||||
ProviderV2.ID.make("google-vertex-anthropic"),
|
||||
ModelV2.ID.make("claude-sonnet-4-6@default"),
|
||||
)
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(languageBaseURL(language)).toBe(
|
||||
"https://aiplatform.us.rep.googleapis.com/v1/projects/test-project/locations/us/publishers/anthropic/models",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("Google Vertex: keeps regional Claude endpoints unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
|
||||
yield* set("VERTEX_LOCATION", "europe-west1")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(
|
||||
ProviderV2.ID.make("google-vertex"),
|
||||
ModelV2.ID.make("claude-sonnet-4-6@default"),
|
||||
)
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(languageBaseURL(language)).toBe(
|
||||
"https://europe-west1-aiplatform.googleapis.com/v1/projects/test-project/locations/europe-west1/publishers/anthropic/models",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("Google Vertex: uses REP endpoint for Gemini continental multi-regions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
|
||||
yield* set("VERTEX_LOCATION", "eu")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini-3.5-flash"))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(languageBaseURL(language)).toBe(
|
||||
"https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/test-project/locations/eu/publishers/google",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("Google Vertex: keeps regional Gemini endpoints unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("GOOGLE_CLOUD_PROJECT", "test-project")
|
||||
yield* set("VERTEX_LOCATION", "europe-west1")
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini-3.5-flash"))
|
||||
const language = yield* provider.getLanguage(model)
|
||||
expect(languageBaseURL(language)).toBe(
|
||||
"https://europe-west1-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/europe-west1/publishers/google",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("cloudflare-ai-gateway loads with env variables", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Option, Ref } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Installation } from "../../src/installation"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
|
||||
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
|
||||
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
|
||||
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
|
||||
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const input = MoveSession.Input.make({
|
||||
sessionID: SessionV2.ID.make("ses_move"),
|
||||
destination: { directory: AbsolutePath.make("/destination") },
|
||||
moveChanges: true,
|
||||
})
|
||||
const called = Ref.makeUnsafe<MoveSession.Input | undefined>(undefined)
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
|
||||
Layer.provide([authorizationLayer, schemaErrorLayer]),
|
||||
// Raw HttpApi routes expose an opaque handler context at the request boundary.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
|
||||
),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provide(Layer.mock(Auth.Service)({})),
|
||||
Layer.provide(Layer.mock(Config.Service)({})),
|
||||
Layer.provide(Layer.mock(Installation.Service)({})),
|
||||
Layer.provide(
|
||||
Layer.mock(MoveSession.Service)({
|
||||
moveSession: (value) => Ref.set(called, value),
|
||||
}),
|
||||
),
|
||||
Layer.provide(ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" })),
|
||||
)
|
||||
const it = testEffect(apiLayer)
|
||||
|
||||
describe("control-plane HttpApi", () => {
|
||||
it.live("moves a session through the root control-plane route", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.post("/experimental/control-plane/move-session").pipe(
|
||||
HttpClientRequest.setBody(HttpBody.jsonUnsafe(input)),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(yield* Ref.get(called)).toEqual(input)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Option } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Installation } from "../../src/installation"
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
|
||||
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
|
||||
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
|
||||
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
|
||||
Layer.provide([authorizationLayer, schemaErrorLayer]),
|
||||
// Raw HttpApi routes expose an opaque handler context at the request boundary.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
|
||||
),
|
||||
{ disableListenLog: true, disableLogger: true },
|
||||
).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provide(Layer.mock(Auth.Service)({})),
|
||||
Layer.provide(Layer.mock(Config.Service)({})),
|
||||
Layer.provide(Layer.mock(MoveSession.Service)({})),
|
||||
Layer.provide(
|
||||
Layer.mock(Installation.Service)({
|
||||
method: () => Effect.succeed("npm"),
|
||||
latest: () => Effect.succeed("9.9.9"),
|
||||
upgrade: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
Layer.provide(ServerAuth.Config.configLayer({ password: Option.none(), username: "opencode" })),
|
||||
)
|
||||
const it = testEffect(apiLayer)
|
||||
|
||||
describe("global HttpApi", () => {
|
||||
it.live("upgrades to latest when the request body is omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.post(GlobalPaths.upgrade)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* response.json).toEqual({ success: true, version: "9.9.9" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects malformed upgrade payloads", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe(
|
||||
HttpClientRequest.setBody(HttpBody.text("{", "application/json")),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(yield* response.json).toEqual({ success: false, error: "Invalid request body" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -335,7 +335,7 @@ describe("session.retry.retryable", () => {
|
||||
expect(retryable).toEqual({ message: "Response decompression failed" })
|
||||
})
|
||||
|
||||
test("maps free limits to Go upsell action", () => {
|
||||
test("maps free limits to managed-provider guidance", () => {
|
||||
const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)(
|
||||
new SessionV1.APIError({
|
||||
message: "Free usage exceeded",
|
||||
@@ -354,7 +354,7 @@ describe("session.retry.retryable", () => {
|
||||
reason: "free_tier_limit",
|
||||
provider: "opencode",
|
||||
title: "Free limit reached",
|
||||
message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
|
||||
message: "Free limit reached on the managed provider. Configure your own provider API key for unlimited use.",
|
||||
label: "subscribe",
|
||||
link: SessionRetry.GO_UPSELL_URL,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user