mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 16:56:33 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aaaac27d0 | |||
| 7fe7b9f258 | |||
| a5ba1d075d | |||
| ba437069e6 | |||
| 387c5a02c8 | |||
| 4b1b722e98 | |||
| 6b03be5468 | |||
| 7d2c1ce6c6 | |||
| a9ef5a0fae | |||
| 0cf9a5d20b | |||
| 2c4ad9f405 | |||
| 3bf054c1d9 | |||
| 14c511e380 | |||
| 5ee0238c4a | |||
| 871d38cbd7 |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -120,7 +120,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -147,7 +147,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -169,7 +169,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -239,7 +239,7 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"venice-ai-sdk-provider": "2.0.1",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -254,7 +254,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"drizzle-orm": "catalog:",
|
||||
@@ -309,7 +309,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -323,7 +323,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -353,7 +353,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -369,7 +369,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -382,7 +382,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -400,7 +400,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -494,7 +494,7 @@
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"venice-ai-sdk-provider": "2.0.1",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
@@ -538,7 +538,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -576,7 +576,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -591,7 +591,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -626,7 +626,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -675,7 +675,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -4912,7 +4912,7 @@
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.37", "@ai-sdk/provider": "^3.0.8", "@ai-sdk/provider-utils": "^4.0.21" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-6SxA8a4MoA6Q/c+D3q7My0Hfog76enN3n0MXhwosM+tso66rXBEGeBRD/0lravRDVzL2Q1w5QJPc86rAVJtfXg=="],
|
||||
"venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="],
|
||||
|
||||
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
||||
|
||||
@@ -5956,7 +5956,11 @@
|
||||
|
||||
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
@@ -6658,6 +6662,10 @@
|
||||
|
||||
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
+10
-1
@@ -3,6 +3,7 @@
|
||||
stdenv,
|
||||
bun,
|
||||
nodejs,
|
||||
darwin,
|
||||
electron_41,
|
||||
makeWrapper,
|
||||
writableTmpDirAsHomeHook,
|
||||
@@ -14,7 +15,12 @@ let
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "opencode-desktop";
|
||||
inherit (opencode) version src node_modules;
|
||||
inherit (opencode)
|
||||
version
|
||||
src
|
||||
node_modules
|
||||
patches
|
||||
;
|
||||
|
||||
nativeBuildInputs = [
|
||||
bun
|
||||
@@ -23,6 +29,9 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
writableTmpDirAsHomeHook
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
autoPatchelfHook
|
||||
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||
darwin.autoSignDarwinBinariesHook
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-1RiaZQHzIhdtcOJUMsLagpP+nBBL/Qu6zQgrAXMHDCI=",
|
||||
"aarch64-linux": "sha256-5QZhtkWuNpY/qUxlKRHcGbILOAVnuyzu+h8VDIuMQcU=",
|
||||
"aarch64-darwin": "sha256-9w3QA22XNc1itGLyhikYU90xGH/iLUUM+SSGXla7lhw=",
|
||||
"x86_64-darwin": "sha256-cSYiyhhSqIYiTeK1uWHDkHbYstQYD5jEB7JaYjWjgi4="
|
||||
"x86_64-linux": "sha256-qq8cKNcW10NRbPx2T/Gpzby4nx2qHzj0LWDwqyPl7rY=",
|
||||
"aarch64-linux": "sha256-UguK4nObAL/ovGZQousveoXCmkcxEgWy7XSSzxVJNEY=",
|
||||
"aarch64-darwin": "sha256-NibSqZiF0XMbFhOtl2CQvFe4N/BXB5UAdt2iKv7dIcI=",
|
||||
"x86_64-darwin": "sha256-ZNeZSpYitdUjXx/sULAIoLXrM6+Vz+GXQxKpdfgXh60="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
import { bootstrapDirectory } from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
|
||||
describe("bootstrapDirectory", () => {
|
||||
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
||||
const [store, setStore] = createStore<State>({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
command: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
provider_ready: true,
|
||||
provider,
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
mcp: {},
|
||||
lsp_ready: true,
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: { list: async () => ({ data: [] }) },
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
mcp: { status: async () => ({ data: {} }) },
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
loadSessions() {},
|
||||
translate: (key) => key,
|
||||
queryClient: new QueryClient(),
|
||||
})
|
||||
|
||||
expect(store.status).toBe("partial")
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||
|
||||
expect(store.status).toBe("complete")
|
||||
})
|
||||
})
|
||||
@@ -220,6 +220,7 @@ export async function bootstrapDirectory(input: {
|
||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
||||
}
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
||||
providerRev.set(input.directory, rev)
|
||||
@@ -326,5 +327,7 @@ export async function bootstrapDirectory(input: {
|
||||
description: formatServerError(slowErrs[0], input.translate),
|
||||
})
|
||||
}
|
||||
|
||||
if (loading && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, getOwner } from "solid-js"
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { createRoot, getOwner, type Owner } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||
import type { State } from "./types"
|
||||
import { createChildStoreManager } from "./child-store"
|
||||
import type { QueryOptionsApi } from "../global-sync"
|
||||
|
||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||
|
||||
const child = () => createStore({} as State)
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
|
||||
const queryOptionsApi = {
|
||||
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
||||
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
|
||||
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
|
||||
path: (directory: string | null) => ({
|
||||
queryKey: [directory, "path"],
|
||||
queryFn: async () => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: "",
|
||||
directory: directory ?? "",
|
||||
home: "",
|
||||
}),
|
||||
}),
|
||||
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
|
||||
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
|
||||
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
|
||||
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
|
||||
} as unknown as QueryOptionsApi
|
||||
|
||||
function createOwner(callback: (owner: Owner) => void) {
|
||||
return createRoot((dispose) => {
|
||||
const owner = getOwner()
|
||||
if (!owner) throw new Error("owner required")
|
||||
callback(owner)
|
||||
|
||||
return dispose
|
||||
})
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@/utils/persist", () => ({
|
||||
Persist: {
|
||||
workspace: (...parts: string[]) => parts.join(":"),
|
||||
},
|
||||
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
|
||||
}))
|
||||
mock.module("@tanstack/solid-query", () => ({
|
||||
useQueries: () => [
|
||||
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
|
||||
{ isLoading: false, data: {} },
|
||||
{ isLoading: false, data: [] },
|
||||
{ isLoading: false, data: provider },
|
||||
],
|
||||
}))
|
||||
|
||||
createChildStoreManager = (await import("./child-store")).createChildStoreManager
|
||||
})
|
||||
|
||||
describe("createChildStoreManager", () => {
|
||||
test("does not evict the active directory during mark", () => {
|
||||
@@ -22,8 +75,8 @@ describe("createChildStoreManager", () => {
|
||||
onBootstrap() {},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: {} as any,
|
||||
global: { provider: null! },
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
})
|
||||
|
||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||
@@ -37,4 +90,35 @@ describe("createChildStoreManager", () => {
|
||||
|
||||
expect(manager.children[directory]).toBeDefined()
|
||||
})
|
||||
|
||||
test("starts new child stores as loading and bootstraps them on first access", () => {
|
||||
const bootstraps: string[] = []
|
||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||
|
||||
const dispose = createOwner((owner) => {
|
||||
manager = createChildStoreManager({
|
||||
owner,
|
||||
isBooting: () => false,
|
||||
isLoadingSessions: () => false,
|
||||
onBootstrap(directory) {
|
||||
bootstraps.push(directory)
|
||||
},
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (!manager) throw new Error("manager required")
|
||||
|
||||
const [store] = manager.child("/project")
|
||||
|
||||
expect(store.status).toBe("loading")
|
||||
expect(bootstraps).toEqual(["/project"])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,7 +202,7 @@ export function createChildStoreManager(input: {
|
||||
return { state: "", config: "", worktree: "", directory: "", home: "" }
|
||||
return pathQuery.data
|
||||
},
|
||||
status: "complete" as const,
|
||||
status: "loading" as const,
|
||||
agent: [],
|
||||
command: [],
|
||||
session: [],
|
||||
|
||||
+57
-181
@@ -1,5 +1,5 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -18,7 +18,6 @@ import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -467,11 +466,6 @@ function LegacyHome() {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const language = useLanguage()
|
||||
|
||||
const [promptText, setPromptText] = createSignal("")
|
||||
const [selectedAgent, setSelectedAgent] = createSignal("frontend-specialist")
|
||||
const [showProjectsDropdown, setShowProjectsDropdown] = createSignal(false)
|
||||
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
const recent = createMemo(() => {
|
||||
return sync.data.project
|
||||
@@ -480,8 +474,6 @@ function LegacyHome() {
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
const currentProject = createMemo(() => recent()[0]?.worktree)
|
||||
|
||||
const serverDotClass = createMemo(() => {
|
||||
const healthy = server.healthy()
|
||||
if (healthy === true) return "bg-icon-success-base"
|
||||
@@ -520,185 +512,69 @@ function LegacyHome() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleModelSelect() {
|
||||
dialog.show(() => <DialogSelectModel />)
|
||||
}
|
||||
|
||||
function toggleAgent() {
|
||||
const agents = ["frontend-specialist", "build", "general"]
|
||||
const nextIndex = (agents.indexOf(selectedAgent()) + 1) % agents.length
|
||||
setSelectedAgent(agents[nextIndex])
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const projectToOpen = currentProject()
|
||||
if (projectToOpen) {
|
||||
openProject(projectToOpen)
|
||||
} else {
|
||||
chooseProject()
|
||||
}
|
||||
}
|
||||
|
||||
const activeModelName = createMemo(() => {
|
||||
const model = sync.data.config.model
|
||||
if (!model) return "GPT-5.7 Pro"
|
||||
const parts = model.split("/")
|
||||
return parts[parts.length - 1]
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="mx-auto mt-24 w-full max-w-2xl px-6 flex flex-col items-center">
|
||||
<div class="flex flex-col items-center gap-3 mb-10">
|
||||
<div onClick={chooseProject} class="cursor-pointer hover:opacity-25 transition-opacity duration-200">
|
||||
<Logo class="w-48 opacity-15" />
|
||||
</div>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
class="text-12-regular text-text-weak px-3"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full mr-2": true,
|
||||
[serverDotClass()]: true,
|
||||
}}
|
||||
/>
|
||||
{server.name}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto mt-55 w-full md:w-auto px-4">
|
||||
<Logo class="md:w-xl opacity-12" />
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="mt-4 mx-auto text-14-regular text-text-weak"
|
||||
onClick={() => dialog.show(() => <DialogSelectServer />)}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-2 rounded-full": true,
|
||||
[serverDotClass()]: true,
|
||||
}}
|
||||
/>
|
||||
{server.name}
|
||||
</Button>
|
||||
<Switch>
|
||||
<Match when={recent().length > 0}>
|
||||
<div class="w-full flex flex-col items-center gap-6">
|
||||
<div class="text-20-medium text-text-strong text-center">{language.t("session.new.title")}</div>
|
||||
|
||||
<div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md relative">
|
||||
<textarea
|
||||
class="bg-transparent border-none outline-none text-14-regular text-text-base placeholder-text-weak w-full resize-none h-20 focus:outline-none"
|
||||
placeholder="Ask anything, / for commands, @ for context..."
|
||||
value={promptText()}
|
||||
onInput={(e) => setPromptText(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
|
||||
onClick={toggleAgent}
|
||||
>
|
||||
<Icon name="sliders" size="small" class="shrink-0" />
|
||||
<span>Agent: {selectedAgent()}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
|
||||
onClick={handleModelSelect}
|
||||
>
|
||||
<Icon name="brain" size="small" class="shrink-0" />
|
||||
<span>Model: {activeModelName()}</span>
|
||||
</Button>
|
||||
|
||||
<div class="relative">
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
|
||||
onClick={() => setShowProjectsDropdown(!showProjectsDropdown())}
|
||||
>
|
||||
<Icon name="folder" size="small" class="shrink-0" />
|
||||
<span>Project: {currentProject() ? getFilename(currentProject()) : "Select Project"}</span>
|
||||
</Button>
|
||||
|
||||
<Show when={showProjectsDropdown()}>
|
||||
<div class="absolute left-0 mt-1 w-64 bg-surface-raised-base border border-border-base rounded-lg p-2 shadow-lg z-50 flex flex-col gap-1">
|
||||
<div class="text-10-semibold text-text-weak px-2 py-1 uppercase tracking-wider">
|
||||
{language.t("home.recentProjects")}
|
||||
</div>
|
||||
<For each={recent()}>
|
||||
{(project) => (
|
||||
<button
|
||||
class="text-12-mono text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center justify-between w-full"
|
||||
onClick={() => {
|
||||
openProject(project.worktree)
|
||||
setShowProjectsDropdown(false)
|
||||
}}
|
||||
>
|
||||
<span class="truncate">{getFilename(project.worktree)}</span>
|
||||
<span class="text-10-regular text-text-weak shrink-0 pl-2">
|
||||
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<div class="border-t border-border-weak-base my-1" />
|
||||
<button
|
||||
class="text-12-medium text-text-strong text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center gap-2 w-full"
|
||||
onClick={() => {
|
||||
setShowProjectsDropdown(false)
|
||||
chooseProject()
|
||||
}}
|
||||
>
|
||||
<Icon name="folder-add-left" size="small" />
|
||||
{language.t("command.project.open")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="text-12-medium text-text-weak flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base border border-border-weak-base rounded-md cursor-default pointer-events-none"
|
||||
>
|
||||
<Icon name="branch" size="small" class="shrink-0" />
|
||||
<span>Branch: dev</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Match when={sync.data.project.length > 0}>
|
||||
<div class="mt-20 w-full flex flex-col gap-4">
|
||||
<div class="flex gap-2 items-center justify-between pl-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
||||
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<For each={recent()}>
|
||||
{(project) => (
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
class="text-14-mono text-left justify-between px-3"
|
||||
onClick={() => openProject(project.worktree)}
|
||||
>
|
||||
{project.worktree.replace(homedir(), "~")}
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!sync.ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
|
||||
<Match when={true}>
|
||||
<div class="w-full flex flex-col items-center gap-6">
|
||||
<div class="text-20-medium text-text-strong text-center">{language.t("home.empty.title")}</div>
|
||||
|
||||
<div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md">
|
||||
<div class="text-14-regular text-text-weak w-full min-h-[4rem] cursor-pointer" onClick={chooseProject}>
|
||||
Ask anything, / for commands, @ for context...
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
|
||||
onClick={chooseProject}
|
||||
>
|
||||
<Icon name="folder" size="small" class="shrink-0" />
|
||||
<span>Open project</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
|
||||
onClick={handleModelSelect}
|
||||
>
|
||||
<Icon name="brain" size="small" class="shrink-0" />
|
||||
<span>Model: {activeModelName()}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<Icon name="folder-add-left" size="large" />
|
||||
<div class="flex flex-col gap-1 items-center justify-center">
|
||||
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
||||
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
||||
</div>
|
||||
<Button class="px-3 mt-1" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -59,6 +59,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { shouldUseV2NewSessionPage } from "@/pages/session/new-session-layout"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -263,7 +264,8 @@ export default function Page() {
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const size = createSizing()
|
||||
const isV2NewSessionPage = () => import.meta.env.VITE_OPENCODE_CHANNEL === "prod" || !params.id
|
||||
const isV2NewSessionPage = () =>
|
||||
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
|
||||
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
|
||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { shouldUseV2NewSessionPage } from "./new-session-layout"
|
||||
|
||||
describe("shouldUseV2NewSessionPage", () => {
|
||||
test("keeps prod session pages on the legacy layout", () => {
|
||||
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
|
||||
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
|
||||
})
|
||||
|
||||
test("uses the v2 layout only for non-prod new-session pages", () => {
|
||||
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
|
||||
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
|
||||
return input.channel !== "prod" && !input.sessionID
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -10,9 +10,12 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
|
||||
const dict = i18n(localeFromRequest(request))
|
||||
|
||||
const limits = Subscription.getFreeLimits()
|
||||
const headersExist = Object.entries(limits.checkHeaders).every(
|
||||
([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false,
|
||||
)
|
||||
// temporarily disable check headers
|
||||
//const headersExist = Object.entries(limits.checkHeaders).every(
|
||||
// ([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false,
|
||||
//)
|
||||
//const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests)
|
||||
const headersExist = true
|
||||
const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests)
|
||||
const isDefaultModel = headersExist && !rateLimit
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -67,7 +67,7 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"venice-ai-sdk-provider": "2.0.1",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
export * as Git from "./git"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { AppProcess } from "./process"
|
||||
|
||||
export interface Repo {
|
||||
/**
|
||||
* The root directory of the working tree that contains the input path.
|
||||
*
|
||||
* For `/home/me/app/src/file.ts` in a normal clone, this is `/home/me/app`.
|
||||
* For `/home/me/app-feature/src/file.ts` in a linked worktree, this is
|
||||
* `/home/me/app-feature`.
|
||||
*/
|
||||
readonly directory: AbsolutePath
|
||||
/**
|
||||
* The shared Git storage directory used by this repo and any linked worktrees.
|
||||
*
|
||||
* For a normal clone at `/home/me/app`, this is usually `/home/me/app/.git`.
|
||||
* For a linked worktree at `/home/me/app-feature` whose main checkout is
|
||||
* `/home/me/app`, this is usually `/home/me/app/.git`.
|
||||
*/
|
||||
readonly store: AbsolutePath
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
|
||||
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
|
||||
readonly roots: (repo: Repo) => Effect.Effect<string[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
|
||||
const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
|
||||
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
if (!dotgit) return undefined
|
||||
|
||||
const cwd = path.dirname(dotgit)
|
||||
const git = run(cwd, proc)
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"])
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"])
|
||||
if (commonDir.exitCode !== 0) return undefined
|
||||
|
||||
return {
|
||||
directory: AbsolutePath.make(topLevel.exitCode === 0 ? resolvePath(cwd, topLevel.text) : cwd),
|
||||
store: AbsolutePath.make(resolvePath(cwd, commonDir.text)),
|
||||
} satisfies Repo
|
||||
})
|
||||
|
||||
const remote = Effect.fn("Git.remote")(function* (repo: Repo, name = "origin") {
|
||||
const result = yield* run(repo.directory, proc)(["remote", "get-url", name])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const roots = Effect.fn("Git.roots")(function* (repo: Repo) {
|
||||
const result = yield* run(repo.directory, proc)(["rev-list", "--max-parents=0", "HEAD"])
|
||||
if (result.exitCode !== 0) return []
|
||||
return result.text
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.toSorted()
|
||||
})
|
||||
|
||||
return Service.of({ find, remote, roots })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
)
|
||||
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
function run(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => ({ exitCode: result.exitCode, text: result.stdout.toString("utf8") }) satisfies Result),
|
||||
Effect.catch(() => Effect.succeed({ exitCode: 1, text: "" } satisfies Result)),
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
const normalized = AppFileSystem.windowsPath(trimmed)
|
||||
if (path.isAbsolute(normalized)) return path.normalize(normalized)
|
||||
return path.resolve(cwd, normalized)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
export * as Project from "./project"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { AbsolutePath, withStatics } from "./schema"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { Git } from "./git"
|
||||
import { Hash } from "./util/hash"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Project.ID"),
|
||||
withStatics((schema) => ({
|
||||
global: schema.make("global"),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Vcs = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("git"),
|
||||
store: AbsolutePath,
|
||||
}),
|
||||
])
|
||||
export type Vcs = typeof Vcs.Type
|
||||
|
||||
export class Info extends Schema.Class<Info>("Project.Info")({
|
||||
id: ID,
|
||||
vcs: Schema.optional(Vcs),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<
|
||||
{
|
||||
previous?: ID
|
||||
id: ID
|
||||
directory: AbsolutePath
|
||||
vcs?: Vcs
|
||||
},
|
||||
never
|
||||
>
|
||||
/**
|
||||
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
|
||||
*
|
||||
* This exists while the old opencode project service and this core project
|
||||
* service work together: core resolves the ID, while the old service still owns
|
||||
* database migration and persistence. The old service should call this after it
|
||||
* finishes migrating from `resolve().previous` to `resolve().id`; once project
|
||||
* persistence moves into core, this separate bridge method can go away.
|
||||
*/
|
||||
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectV2") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((value) => value.trim()),
|
||||
Effect.map((value) => (value ? ID.make(value) : undefined)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
})
|
||||
|
||||
const remote = Effect.fnUntraced(function* (repo: Git.Repo) {
|
||||
const origin = yield* git.remote(repo)
|
||||
if (!origin) return undefined
|
||||
const normalized = url(origin)
|
||||
if (!normalized) return undefined
|
||||
return ID.make(Hash.fast(`git-remote:${normalized}`))
|
||||
})
|
||||
|
||||
function url(input: string) {
|
||||
const value = input.trim()
|
||||
if (!value) return undefined
|
||||
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol === "file:") return undefined
|
||||
return parts(parsed.hostname, parsed.pathname)
|
||||
} catch {
|
||||
const scp = value.match(/^([^@/:]+@)?([^/:]+):(.+)$/)
|
||||
if (scp) return parts(scp[2], scp[3])
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parts(host: string, name: string) {
|
||||
const pathname = name
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\.git\/?$/, "")
|
||||
.replace(/\/+$/, "")
|
||||
if (!host || !pathname) return undefined
|
||||
return `${host.toLowerCase()}/${pathname}`
|
||||
}
|
||||
|
||||
const root = Effect.fnUntraced(function* (repo: Git.Repo) {
|
||||
const root = (yield* git.roots(repo))[0]
|
||||
return root ? ID.make(root) : undefined
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
|
||||
const repo = yield* git.find(input)
|
||||
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
|
||||
|
||||
const previous = yield* cached(repo.store)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
|
||||
return {
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
directory: repo.directory,
|
||||
vcs: { type: "git" as const, store: repo.store },
|
||||
}
|
||||
})
|
||||
|
||||
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
||||
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
return Service.of({ resolve, commit })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Option, Schema, SchemaGetter } from "effect"
|
||||
|
||||
export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
|
||||
export type AbsolutePath = typeof AbsolutePath.Type
|
||||
|
||||
export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
|
||||
export type RelativePath = typeof RelativePath.Type
|
||||
|
||||
/**
|
||||
* Integer greater than zero.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Project.defaultLayer)
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
function abs(value: string) {
|
||||
return AbsolutePath.make(value)
|
||||
}
|
||||
|
||||
function real(value: string) {
|
||||
return Effect.promise(() => fs.realpath(value)).pipe(Effect.map((value) => AbsolutePath.make(value)))
|
||||
}
|
||||
|
||||
async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) {
|
||||
await $`git init`.cwd(dir).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(dir).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(dir).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(dir).quiet()
|
||||
await $`git config user.name Test`.cwd(dir).quiet()
|
||||
if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet()
|
||||
if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
|
||||
}
|
||||
|
||||
async function rootCommit(dir: string) {
|
||||
return (await $`git rev-list --max-parents=0 HEAD`.cwd(dir).text()).trim()
|
||||
}
|
||||
|
||||
describe("ProjectV2.resolve", () => {
|
||||
it.live("returns global for non-git directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make("global"))
|
||||
expect(path.resolve(result.directory)).toBe(path.resolve(tmp.path))
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns git global for repo with no commits and no remote", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make("global"))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("falls back to root commit when origin is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers normalized origin over root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:Acme/App.git" }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(remoteID("github.com/Acme/App"))
|
||||
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("normalizes ssh and https remotes to the same id", () =>
|
||||
Effect.gen(function* () {
|
||||
const ssh = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const https = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const a = yield* project.resolve(abs(ssh.path))
|
||||
const b = yield* project.resolve(abs(https.path))
|
||||
|
||||
expect(a.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("ignores file remotes and falls back to root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: `file://${tmp.path}` }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns previous cached id from common dir", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not write the cache while resolving", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
yield* project.resolve(abs(tmp.path))
|
||||
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("resolves from nested directories to repo root", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "a", "b"), { recursive: true }))
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(path.join(tmp.path, "a", "b")))
|
||||
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("linked worktree returns opened worktree directory and previous from common dir", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const worktree = `${tmp.path}-worktree`
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => $`rm -rf ${worktree}`.quiet().nothrow()).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(tmp.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp.path, ".git", "opencode"), "old-id"))
|
||||
yield* Effect.promise(() => $`git worktree add ${worktree} -b test-${Date.now()}`.cwd(tmp.path).quiet())
|
||||
const project = yield* Project.Service
|
||||
|
||||
const result = yield* project.resolve(abs(worktree))
|
||||
|
||||
expect(result.directory).toBe(yield* real(worktree))
|
||||
expect(result.previous).toBe(Project.ID.make("old-id"))
|
||||
expect(result.id).toBe(remoteID("github.com/owner/repo"))
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import windowState from "electron-window-state"
|
||||
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
|
||||
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
|
||||
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
@@ -15,6 +18,11 @@ const rendererHost = "renderer"
|
||||
const clipboardWritePermission = "clipboard-sanitized-write"
|
||||
const notificationPermission = "notifications"
|
||||
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
|
||||
const oc2Theme = oc2ThemeJson as DesktopTheme
|
||||
const oc2Background = {
|
||||
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
|
||||
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
|
||||
}
|
||||
const documentPolicyHeader = "Document-Policy"
|
||||
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
|
||||
|
||||
@@ -46,6 +54,7 @@ export function setRelaunchHandler(handler: () => void) {
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
backgroundColor = color
|
||||
BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
|
||||
}
|
||||
|
||||
export function getBackgroundColor(): string | undefined {
|
||||
@@ -65,6 +74,10 @@ function tone() {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
||||
}
|
||||
|
||||
function defaultBackgroundColor() {
|
||||
return oc2Background[tone()]
|
||||
}
|
||||
|
||||
function overlay(theme: Partial<TitlebarTheme> = {}, zoom = 1) {
|
||||
const mode = theme.mode ?? tone()
|
||||
return {
|
||||
@@ -120,7 +133,7 @@ export function createMainWindow() {
|
||||
autoHideMenuBar: true,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hidden" as const,
|
||||
@@ -178,7 +191,7 @@ export function createLoadingWindow() {
|
||||
show: true,
|
||||
autoHideMenuBar: true,
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
backgroundColor: backgroundColor ?? defaultBackgroundColor(),
|
||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
||||
...(process.platform === "win32"
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.15.9"
|
||||
version = "1.15.10"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.9/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ProviderOptions } from "./utils/provider-options"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "anthropic-messages"
|
||||
@@ -136,6 +137,7 @@ const AnthropicTool = Schema.Struct({
|
||||
description: Schema.String,
|
||||
input_schema: JsonObject,
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
eager_input_streaming: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
|
||||
|
||||
@@ -144,10 +146,10 @@ const AnthropicToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
||||
])
|
||||
|
||||
const AnthropicThinking = Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
})
|
||||
// Anthropic accepts several `thinking` shapes (enabled with `budget_tokens`,
|
||||
// adaptive with optional `display`, and disabled). The body schema permits the
|
||||
// full union so explicit lowering can pick the correct fields per model.
|
||||
const AnthropicThinkingBody = Schema.Record(Schema.String, Schema.Unknown)
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
model: Schema.String,
|
||||
@@ -161,9 +163,12 @@ const AnthropicBodyFields = {
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
top_k: Schema.optional(Schema.Number),
|
||||
stop_sequences: optionalArray(Schema.String),
|
||||
thinking: Schema.optional(AnthropicThinking),
|
||||
thinking: Schema.optional(AnthropicThinkingBody),
|
||||
}
|
||||
const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
// Unknown provider options pass through verbatim with top-level keys snake-cased.
|
||||
const AnthropicMessagesBody = Schema.StructWithRest(Schema.Struct(AnthropicBodyFields), [
|
||||
Schema.Record(Schema.String, Schema.Any),
|
||||
])
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
|
||||
const AnthropicUsage = Schema.Struct({
|
||||
@@ -254,11 +259,16 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
|
||||
const lowerTool = (
|
||||
breakpoints: Cache.Breakpoints,
|
||||
tool: ToolDefinition,
|
||||
eagerInputStreaming: boolean | undefined,
|
||||
): AnthropicTool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.inputSchema,
|
||||
cache_control: cacheControl(breakpoints, tool.cache),
|
||||
eager_input_streaming: eagerInputStreaming ? true : undefined,
|
||||
})
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
@@ -335,7 +345,9 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
|
||||
// Text / json / error results stay as a string for backward compatibility
|
||||
// with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
return yield* Effect.forEach(part.result.value, lowerToolResultContentItem)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<ToolResultContentPart> = part.result.value
|
||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
@@ -411,24 +423,46 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic
|
||||
// Typed AI SDK Anthropic options. Mirrors the subset of
|
||||
// `anthropicLanguageModelOptions` that opencode's provider transform actually
|
||||
// emits today (see `packages/opencode/src/provider/transform.ts`). Unknown
|
||||
// keys flow through the index signature and pass through to the wire body
|
||||
// with their top-level key snake-cased.
|
||||
type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max"
|
||||
type AnthropicThinking =
|
||||
| { readonly type: "enabled"; readonly budgetTokens?: number; readonly budget_tokens?: number }
|
||||
| { readonly type: "adaptive"; readonly display?: "omitted" | "summarized" }
|
||||
| { readonly type: "disabled" }
|
||||
|
||||
const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
|
||||
const thinking = anthropicOptions(request)?.thinking
|
||||
if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined
|
||||
const budget =
|
||||
typeof thinking.budgetTokens === "number"
|
||||
? thinking.budgetTokens
|
||||
: typeof thinking.budget_tokens === "number"
|
||||
? thinking.budget_tokens
|
||||
: undefined
|
||||
if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens")
|
||||
interface AnthropicOptions {
|
||||
readonly thinking?: AnthropicThinking
|
||||
readonly effort?: AnthropicEffort
|
||||
readonly toolStreaming?: boolean
|
||||
readonly [extra: string]: unknown
|
||||
}
|
||||
|
||||
const ANTHROPIC_KNOWN_KEYS: ReadonlySet<string> = new Set(["thinking", "effort", "toolStreaming"])
|
||||
|
||||
const lowerThinking = (thinking: AnthropicOptions["thinking"]) => {
|
||||
if (thinking === undefined) return undefined
|
||||
if (thinking.type === "disabled") return undefined
|
||||
if (thinking.type === "adaptive") {
|
||||
return { type: "adaptive" as const, ...(thinking.display ? { display: thinking.display } : {}) }
|
||||
}
|
||||
const budget = thinking.budgetTokens ?? thinking.budget_tokens
|
||||
if (budget === undefined) return undefined
|
||||
return { type: "enabled" as const, budget_tokens: budget }
|
||||
})
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const generation = request.generation
|
||||
const options = ProviderOptions.merge(request, ["anthropic"]) as AnthropicOptions
|
||||
// AI SDK's `toolStreaming` controls per-tool `eager_input_streaming`. opencode
|
||||
// sets `toolStreaming: false` for non-Claude models routed through
|
||||
// `@ai-sdk/anthropic`; otherwise the field is left unset so the provider
|
||||
// applies its own default.
|
||||
const eagerInputStreaming = options.toolStreaming === true
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
@@ -436,7 +470,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
const tools =
|
||||
request.tools.length === 0 || request.toolChoice?.type === "none"
|
||||
? undefined
|
||||
: request.tools.map((tool) => lowerTool(breakpoints, tool))
|
||||
: request.tools.map((tool) => lowerTool(breakpoints, tool, eagerInputStreaming))
|
||||
const system =
|
||||
request.system.length === 0
|
||||
? undefined
|
||||
@@ -452,6 +486,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
return {
|
||||
...ProviderOptions.passthrough(options, ANTHROPIC_KNOWN_KEYS),
|
||||
model: request.model.id,
|
||||
system,
|
||||
messages,
|
||||
@@ -463,7 +498,8 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: yield* lowerThinking(request),
|
||||
thinking: lowerThinking(options.thinking),
|
||||
...(options.effort !== undefined ? { effort: options.effort } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "../schema"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { ProviderOptions } from "./utils/provider-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
@@ -50,6 +51,10 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
// `reasoning_content` is a plain string per DeepSeek/OpenAI-compatible spec.
|
||||
// `reasoning_details` is an OpenRouter-style array of typed reasoning objects
|
||||
// (summary / encrypted / text). We accept the structured payload as-is so it
|
||||
// round-trips verbatim to the provider on continuation requests.
|
||||
const OpenAIChatMessage = Schema.Union([
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.Literal("user"), content: Schema.String }),
|
||||
@@ -58,6 +63,7 @@ const OpenAIChatMessage = Schema.Union([
|
||||
content: Schema.NullOr(Schema.String),
|
||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||
reasoning_content: Schema.optional(Schema.String),
|
||||
reasoning_details: Schema.optional(Schema.Array(Schema.Record(Schema.String, Schema.Unknown))),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
@@ -79,7 +85,7 @@ export const bodyFields = {
|
||||
stream: Schema.Literal(true),
|
||||
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||
reasoning_effort: Schema.optional(Schema.String),
|
||||
max_tokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
@@ -88,7 +94,7 @@ export const bodyFields = {
|
||||
seed: Schema.optional(Schema.Number),
|
||||
stop: optionalArray(Schema.String),
|
||||
}
|
||||
const OpenAIChatBody = Schema.Struct(bodyFields)
|
||||
const OpenAIChatBody = Schema.StructWithRest(Schema.Struct(bodyFields), [Schema.Record(Schema.String, Schema.Any)])
|
||||
export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
|
||||
|
||||
// =============================================================================
|
||||
@@ -125,9 +131,16 @@ const OpenAIChatToolCallDelta = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
||||
|
||||
// Streaming reasoning fields. `reasoning_content` (DeepSeek) and `reasoning`
|
||||
// (AI SDK fallback) are strings; `reasoning_details` (OpenRouter) is an array
|
||||
// of typed reasoning detail objects. We surface their plaintext via reasoning
|
||||
// deltas and preserve the structured array for downstream round-trip.
|
||||
const OpenAIChatReasoningDetail = Schema.Record(Schema.String, Schema.Unknown)
|
||||
const OpenAIChatDelta = Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
reasoning_content: optionalNull(Schema.String),
|
||||
reasoning: optionalNull(Schema.String),
|
||||
reasoning_details: optionalNull(Schema.Array(OpenAIChatReasoningDetail)),
|
||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||
})
|
||||
|
||||
@@ -188,6 +201,16 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
|
||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
||||
|
||||
// `reasoning_details` rounds-trips the OpenRouter structured array. Accept the
|
||||
// array shape as canonical; tolerate a string for legacy callers that already
|
||||
// flattened it.
|
||||
const openAICompatibleReasoningDetails = (native: unknown) => {
|
||||
if (!isRecord(native)) return undefined
|
||||
const value = native.reasoning_details
|
||||
if (Array.isArray(value)) return value as ReadonlyArray<Record<string, unknown>>
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||
const content: TextPart[] = []
|
||||
for (const part of message.content) {
|
||||
@@ -220,6 +243,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||
reasoning_content: openAICompatibleReasoningContent(message.native?.openaiCompatible),
|
||||
reasoning_details: openAICompatibleReasoningDetails(message.native?.openaiCompatible),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -246,13 +270,14 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
})
|
||||
|
||||
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
|
||||
const store = OpenAIOptions.store(request)
|
||||
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
|
||||
if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort))
|
||||
return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`)
|
||||
const options = OpenAIOptions.options(request)
|
||||
const effort = options.reasoningEffort
|
||||
if (effort !== undefined && !OpenAIOptions.isReasoningEffort(effort))
|
||||
return yield* invalid(`OpenAI Chat does not support reasoning effort ${effort}`)
|
||||
return {
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
||||
...ProviderOptions.passthrough(options, OpenAIOptions.KNOWN_KEYS),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(effort !== undefined ? { reasoning_effort: effort } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -325,8 +350,15 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
|
||||
let lifecycle = state.lifecycle
|
||||
|
||||
if (delta?.reasoning_content)
|
||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
||||
// OpenRouter-style `reasoning_details` ships an array of typed reasoning
|
||||
// objects (summary / text / encrypted). Concatenate the plaintext fields
|
||||
// into the reasoning delta stream; the structured array is preserved on
|
||||
// the assistant message for round-trip via `providerMetadata`.
|
||||
const detailText = (delta?.reasoning_details ?? [])
|
||||
.map((detail) => (typeof detail.text === "string" ? detail.text : typeof detail.summary === "string" ? detail.summary : ""))
|
||||
.join("")
|
||||
const reasoning = delta?.reasoning_content ?? delta?.reasoning ?? (detailText.length > 0 ? detailText : undefined)
|
||||
if (reasoning) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning)
|
||||
|
||||
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
|
||||
|
||||
@@ -1,22 +1,84 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Route, type RouteRoutedModelInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import type { LLMRequest } from "../schema"
|
||||
import { ProviderOptions } from "./utils/provider-options"
|
||||
import * as OpenAIChat from "./openai-chat"
|
||||
|
||||
const ADAPTER = "openai-compatible-chat"
|
||||
|
||||
export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
|
||||
|
||||
const OpenAICompatibleChatBody = Schema.StructWithRest(
|
||||
Schema.Struct({ ...OpenAIChat.bodyFields, reasoning_effort: Schema.optional(Schema.String) }),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
)
|
||||
export type OpenAICompatibleChatBody = Schema.Schema.Type<typeof OpenAICompatibleChatBody>
|
||||
|
||||
// Typed AI SDK `@ai-sdk/openai-compatible` options. Known keys are lowered
|
||||
// explicitly; everything else passes through to the wire body with its
|
||||
// top-level key snake-cased.
|
||||
interface CompatibleOptions {
|
||||
readonly user?: string
|
||||
readonly reasoningEffort?: string
|
||||
readonly textVerbosity?: string
|
||||
readonly strictJsonSchema?: boolean
|
||||
readonly [extra: string]: unknown
|
||||
}
|
||||
const COMPATIBLE_KNOWN_KEYS: ReadonlySet<string> = new Set([
|
||||
"user",
|
||||
"reasoningEffort",
|
||||
"textVerbosity",
|
||||
"strictJsonSchema",
|
||||
])
|
||||
|
||||
// Match AI SDK `@ai-sdk/openai-compatible` option resolution: the deprecated
|
||||
// `openai-compatible` alias, the canonical `openaiCompatible` key, the raw
|
||||
// provider name (dot-split so e.g. `opencode.internal` matches `opencode`),
|
||||
// and its camelCase variant. Later sources override earlier ones.
|
||||
const bodyOptions = (request: LLMRequest) => {
|
||||
const provider = String(request.model.provider).split(".")[0]
|
||||
const camel = provider.replace(/[_-]([a-z])/g, (_, value: string) => value.toUpperCase())
|
||||
const options = ProviderOptions.merge(request, [
|
||||
"openai-compatible",
|
||||
"openaiCompatible",
|
||||
provider,
|
||||
camel,
|
||||
]) as CompatibleOptions
|
||||
return {
|
||||
...ProviderOptions.passthrough(options, COMPATIBLE_KNOWN_KEYS),
|
||||
...(options.user !== undefined ? { user: options.user } : {}),
|
||||
...(options.reasoningEffort !== undefined ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||
...(options.textVerbosity !== undefined ? { verbosity: options.textVerbosity } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
schema: OpenAICompatibleChatBody,
|
||||
// Drop providerOptions before delegating so OpenAI Chat's OpenAI-only
|
||||
// option validation does not reject compatible-route requests whose
|
||||
// provider id happens to be `openai` or use extended reasoning efforts.
|
||||
from: (request) =>
|
||||
OpenAIChat.protocol.body
|
||||
.from({ ...request, providerOptions: undefined })
|
||||
.pipe(Effect.map((body) => ({ ...body, ...bodyOptions(request) }))),
|
||||
},
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
/**
|
||||
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible
|
||||
* `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and
|
||||
* overrides only the route id so providers can be resolved per-family without
|
||||
* colliding with native OpenAI. Provider helpers configure the route endpoint
|
||||
* before model selection.
|
||||
* `/chat/completions` endpoint. Reuses OpenAI Chat streaming behavior while
|
||||
* allowing compatible providers to pass through additional request-body
|
||||
* options such as `enable_thinking` and extended reasoning efforts.
|
||||
*/
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
protocol: OpenAIChat.protocol,
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { ProviderOptions } from "./utils/provider-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
@@ -111,12 +112,23 @@ const OpenAIResponsesCoreFields = {
|
||||
tools: optionalArray(OpenAIResponsesTool),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
conversation: Schema.optional(Schema.String),
|
||||
max_tool_calls: Schema.optional(Schema.Number),
|
||||
metadata: Schema.optional(JsonObject),
|
||||
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
||||
previous_response_id: Schema.optional(Schema.String),
|
||||
prompt_cache_key: Schema.optional(Schema.String),
|
||||
include: optionalArray(Schema.Literal("reasoning.encrypted_content")),
|
||||
prompt_cache_retention: Schema.optional(Schema.String),
|
||||
safety_identifier: Schema.optional(Schema.String),
|
||||
service_tier: Schema.optional(Schema.String),
|
||||
top_logprobs: Schema.optional(Schema.Number),
|
||||
truncation: Schema.optional(Schema.String),
|
||||
user: Schema.optional(Schema.String),
|
||||
include: optionalArray(Schema.String),
|
||||
reasoning: Schema.optional(
|
||||
Schema.Struct({
|
||||
effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||
summary: Schema.optional(Schema.Literal("auto")),
|
||||
summary: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
text: Schema.optional(
|
||||
@@ -129,10 +141,15 @@ const OpenAIResponsesCoreFields = {
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
}
|
||||
|
||||
const OpenAIResponsesBody = Schema.Struct({
|
||||
...OpenAIResponsesCoreFields,
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
// Unknown provider options are passed through verbatim with their top-level
|
||||
// key snake-cased; the rest record validates them against any JSON value.
|
||||
const OpenAIResponsesBody = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
...OpenAIResponsesCoreFields,
|
||||
stream: Schema.Literal(true),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
)
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
|
||||
@@ -293,14 +310,15 @@ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")
|
||||
// Text/json/error results are encoded as a plain string for backward
|
||||
// compatibility with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
return yield* Effect.forEach(part.result.value, lowerToolResultContentItem)
|
||||
const items: ReadonlyArray<ToolResultContentPart> = part.result.value
|
||||
return yield* Effect.forEach(items, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
|
||||
const system: OpenAIResponsesInputItem[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const input: OpenAIResponsesInputItem[] = [...system]
|
||||
const store = OpenAIOptions.store(request)
|
||||
const store = OpenAIOptions.options(request).store
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "user") {
|
||||
@@ -355,25 +373,47 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
return input
|
||||
})
|
||||
|
||||
const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) {
|
||||
const store = OpenAIOptions.store(request)
|
||||
const promptCacheKey = OpenAIOptions.promptCacheKey(request)
|
||||
const effort = OpenAIOptions.reasoningEffort(request)
|
||||
if (effort && !OpenAIOptions.isReasoningEffort(effort))
|
||||
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
|
||||
const summary = OpenAIOptions.reasoningSummary(request)
|
||||
const encryptedState = OpenAIOptions.encryptedReasoning(request)
|
||||
const verbosity = OpenAIOptions.textVerbosity(request)
|
||||
const instructions = OpenAIOptions.instructions(request)
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenAIOptions.options(request)
|
||||
// OpenAI Responses does not accept the `max` reasoning effort variant.
|
||||
const effort = OpenAIOptions.isReasoningEffort(options.reasoningEffort) ? options.reasoningEffort : undefined
|
||||
const summary = options.reasoningSummary
|
||||
const verbosity = options.textVerbosity
|
||||
// `logprobs` is enabled only by `true` or a numeric top-N. `false` and
|
||||
// `undefined` leave the request without the logprobs include + top_logprobs.
|
||||
const logprobsEnabled = options.logprobs === true || typeof options.logprobs === "number"
|
||||
const include = (() => {
|
||||
const base = options.include ? [...options.include] : []
|
||||
if (options.includeEncryptedReasoning && !base.includes("reasoning.encrypted_content")) {
|
||||
base.push("reasoning.encrypted_content")
|
||||
}
|
||||
if (logprobsEnabled && !base.includes("message.output_text.logprobs")) {
|
||||
base.push("message.output_text.logprobs")
|
||||
}
|
||||
return base.length > 0 ? base : undefined
|
||||
})()
|
||||
const topLogprobs = typeof options.logprobs === "number" ? options.logprobs : options.logprobs === true ? 20 : undefined
|
||||
return {
|
||||
...(instructions ? { instructions } : {}),
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
|
||||
...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}),
|
||||
...(effort || summary ? { reasoning: { effort, summary } } : {}),
|
||||
...(verbosity ? { text: { verbosity } } : {}),
|
||||
...ProviderOptions.passthrough(options, OpenAIOptions.KNOWN_KEYS),
|
||||
...(options.instructions !== undefined ? { instructions: options.instructions } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.conversation !== undefined ? { conversation: options.conversation } : {}),
|
||||
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
|
||||
...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
|
||||
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
|
||||
...(options.previousResponseId !== undefined ? { previous_response_id: options.previousResponseId } : {}),
|
||||
...(options.promptCacheKey !== undefined ? { prompt_cache_key: options.promptCacheKey } : {}),
|
||||
...(options.promptCacheRetention !== undefined ? { prompt_cache_retention: options.promptCacheRetention } : {}),
|
||||
...(options.safetyIdentifier !== undefined ? { safety_identifier: options.safetyIdentifier } : {}),
|
||||
...(options.serviceTier !== undefined ? { service_tier: options.serviceTier } : {}),
|
||||
...(topLogprobs !== undefined ? { top_logprobs: topLogprobs } : {}),
|
||||
...(options.truncation !== undefined ? { truncation: options.truncation } : {}),
|
||||
...(options.user !== undefined ? { user: options.user } : {}),
|
||||
...(include ? { include } : {}),
|
||||
...(effort !== undefined || summary !== undefined ? { reasoning: { effort, summary } } : {}),
|
||||
...(verbosity !== undefined ? { text: { verbosity } } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const generation = request.generation
|
||||
@@ -386,7 +426,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
...(yield* lowerOptions(request)),
|
||||
...lowerOptions(request),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,60 +1,73 @@
|
||||
import { Schema } from "effect"
|
||||
import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema"
|
||||
import { ReasoningEfforts, TextVerbosity } from "../../schema"
|
||||
import type { LLMRequest } from "../../schema"
|
||||
import { ReasoningEfforts, TextVerbosity, type ReasoningEffort } from "../../schema"
|
||||
import { ProviderOptions } from "./provider-options"
|
||||
|
||||
export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
|
||||
(effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
|
||||
)
|
||||
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
|
||||
|
||||
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
|
||||
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
||||
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
||||
|
||||
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
|
||||
export const OpenAITextVerbosity = TextVerbosity
|
||||
|
||||
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
|
||||
typeof effort === "string" && REASONING_EFFORTS.has(effort)
|
||||
|
||||
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
|
||||
typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
|
||||
|
||||
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
|
||||
typeof value === "string" && TEXT_VERBOSITY.has(value)
|
||||
|
||||
const options = (request: LLMRequest) => request.providerOptions?.openai
|
||||
|
||||
export const store = (request: LLMRequest): boolean | undefined => {
|
||||
const value = options(request)?.store
|
||||
return typeof value === "boolean" ? value : undefined
|
||||
// Typed AI SDK OpenAI options. Mirrors the camelCase surface AI SDK accepts.
|
||||
// Known keys are typed; everything else passes through to the wire body with
|
||||
// its top-level key snake-cased.
|
||||
export interface Options {
|
||||
readonly store?: boolean
|
||||
readonly promptCacheKey?: string
|
||||
readonly promptCacheRetention?: string
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly reasoningSummary?: string
|
||||
readonly textVerbosity?: "low" | "medium" | "high"
|
||||
readonly include?: ReadonlyArray<string>
|
||||
readonly includeEncryptedReasoning?: boolean
|
||||
readonly instructions?: string
|
||||
readonly conversation?: string
|
||||
readonly maxToolCalls?: number
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly parallelToolCalls?: boolean
|
||||
readonly previousResponseId?: string
|
||||
readonly safetyIdentifier?: string
|
||||
readonly serviceTier?: string
|
||||
readonly logprobs?: boolean | number
|
||||
readonly truncation?: string
|
||||
readonly user?: string
|
||||
readonly [extra: string]: unknown
|
||||
}
|
||||
|
||||
export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => {
|
||||
const value = options(request)?.reasoningEffort
|
||||
return isAnyReasoningEffort(value) ? value : undefined
|
||||
}
|
||||
export const KNOWN_KEYS: ReadonlySet<string> = new Set([
|
||||
"store",
|
||||
"promptCacheKey",
|
||||
"promptCacheRetention",
|
||||
"reasoningEffort",
|
||||
"reasoningSummary",
|
||||
"textVerbosity",
|
||||
"include",
|
||||
"includeEncryptedReasoning",
|
||||
"instructions",
|
||||
"conversation",
|
||||
"maxToolCalls",
|
||||
"metadata",
|
||||
"parallelToolCalls",
|
||||
"previousResponseId",
|
||||
"safetyIdentifier",
|
||||
"serviceTier",
|
||||
"logprobs",
|
||||
"truncation",
|
||||
"user",
|
||||
])
|
||||
|
||||
export const reasoningSummary = (request: LLMRequest): "auto" | undefined => {
|
||||
return options(request)?.reasoningSummary === "auto" ? "auto" : undefined
|
||||
}
|
||||
|
||||
export const encryptedReasoning = (request: LLMRequest) =>
|
||||
options(request)?.includeEncryptedReasoning === true ? true : undefined
|
||||
|
||||
export const promptCacheKey = (request: LLMRequest) => {
|
||||
const value = options(request)?.promptCacheKey
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export const textVerbosity = (request: LLMRequest) => {
|
||||
const value = options(request)?.textVerbosity
|
||||
return isTextVerbosity(value) ? value : undefined
|
||||
}
|
||||
|
||||
export const instructions = (request: LLMRequest) => {
|
||||
const value = options(request)?.instructions
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
// Read the merged `openai` provider option bag. Producers
|
||||
// (`packages/opencode/src/provider/transform.ts`) emit typed values; we widen
|
||||
// only the index signature so passthrough keys remain reachable. Invalid
|
||||
// shapes surface in the lowerer where they're consumed, not at decode time.
|
||||
export const options = (request: LLMRequest): Options => ProviderOptions.merge(request, ["openai"]) as Options
|
||||
|
||||
export * as OpenAIOptions from "./openai-options"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { LLMRequest } from "../../schema"
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
|
||||
// Convert a single top-level option key from camelCase to snake_case. Values
|
||||
// are left verbatim — recursive conversion would mangle structured payloads
|
||||
// (IDs, nested provider-shaped objects) and provider APIs do not require it.
|
||||
// PascalCase (`FooBar`) becomes `foo_bar` without a leading underscore.
|
||||
export const snakeKey = (key: string) =>
|
||||
key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase()
|
||||
|
||||
// Merge provider option namespaces using AI SDK precedence semantics: later
|
||||
// sources override earlier ones, missing namespaces are skipped. Used by every
|
||||
// native protocol that reads request-level provider options.
|
||||
export const merge = (request: LLMRequest, keys: ReadonlyArray<string>) => {
|
||||
const sources = keys.map((key) => request.providerOptions?.[key]).filter(isRecord)
|
||||
return Object.assign({}, ...sources) as Record<string, unknown>
|
||||
}
|
||||
|
||||
// Spread the unknown remainder of a merged option bag onto a provider body.
|
||||
// `consumed` lists keys already lowered explicitly so they aren't duplicated
|
||||
// or echoed at the wrong shape.
|
||||
export const passthrough = (options: Record<string, unknown>, consumed: ReadonlySet<string>) => {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
if (consumed.has(key)) continue
|
||||
if (value === undefined) continue
|
||||
result[snakeKey(key)] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export * as ProviderOptions from "./provider-options"
|
||||
@@ -209,6 +209,80 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers Anthropic thinking provider option (enabled)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "think",
|
||||
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 12345 } } },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body).toMatchObject({ thinking: { type: "enabled", budget_tokens: 12345 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers Anthropic adaptive thinking with effort sibling", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "think",
|
||||
providerOptions: { anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "max" } },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body).toMatchObject({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "max",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sets per-tool eager_input_streaming only when toolStreaming is true", () =>
|
||||
Effect.gen(function* () {
|
||||
const off = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "use tool",
|
||||
providerOptions: { anthropic: { toolStreaming: false } },
|
||||
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
)
|
||||
expect(off.body.tools?.[0]?.eager_input_streaming).toBeUndefined()
|
||||
|
||||
const on = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "use tool",
|
||||
providerOptions: { anthropic: { toolStreaming: true } },
|
||||
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
)
|
||||
expect(on.body.tools?.[0]?.eager_input_streaming).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes unknown Anthropic provider options through with snake-cased keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "go",
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
anthropicBeta: ["claude-2024-07-15"],
|
||||
customField: { keepCamelCase: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(prepared.body).toMatchObject({
|
||||
anthropic_beta: ["claude-2024-07-15"],
|
||||
custom_field: { keepCamelCase: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Auth, LLMClient } from "../../src/route"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse } from "../lib/http"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
|
||||
const Json = Schema.fromJsonString(Schema.Unknown)
|
||||
@@ -199,6 +199,134 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes through compatible options and prior reasoning for tool continuations", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
providerOptions: {
|
||||
deepseek: {
|
||||
reasoningEffort: "max",
|
||||
textVerbosity: "low",
|
||||
promptCacheKey: "session_123",
|
||||
strictJsonSchema: false,
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
Message.user("Audit the site"),
|
||||
Message.make({
|
||||
role: "assistant",
|
||||
native: { openaiCompatible: { reasoning_content: "I should inspect the page." } },
|
||||
content: [ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "page" } })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
reasoning_effort: "max",
|
||||
verbosity: "low",
|
||||
prompt_cache_key: "session_123",
|
||||
enable_thinking: true,
|
||||
messages: [
|
||||
{ role: "user", content: "Audit the site" },
|
||||
{
|
||||
role: "assistant",
|
||||
reasoning_content: "I should inspect the page.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
function: { name: "lookup", arguments: '{"query":"page"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(prepared.body).not.toHaveProperty("strictJsonSchema")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves structured reasoning_details on compatible continuations", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.text", text: "Let me work through this.", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.encrypted", data: "sha256:abc123", format: "anthropic-claude-v1", index: 1 },
|
||||
]
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.make({
|
||||
role: "assistant",
|
||||
native: { openaiCompatible: { reasoning_details: details } },
|
||||
content: [ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
messages: [{ role: "assistant", reasoning_details: details }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves dot-scoped compatible provider options", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: OpenAICompatibleChat.route
|
||||
.with({ provider: "opencode.internal", endpoint: { baseURL: "https://api.example.test/v1" } })
|
||||
.model({ id: "reasoning-model" }),
|
||||
prompt: "Think.",
|
||||
providerOptions: { opencode: { reasoningEffort: "max", enable_thinking: true } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({ reasoning_effort: "max", enable_thinking: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not apply OpenAI effort limits to compatible providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: OpenAICompatibleChat.route
|
||||
.with({ provider: "openai", endpoint: { baseURL: "https://compatible.example.test/v1" } })
|
||||
.model({ id: "reasoning-model" }),
|
||||
prompt: "Think.",
|
||||
providerOptions: { openai: { reasoningEffort: "max" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({ reasoning_effort: "max" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses compatible reasoning field variants", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ reasoning: "fallback" }),
|
||||
deltaChunk({
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.text", text: " text-detail", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.summary", summary: " summary-detail", format: "anthropic-claude-v1", index: 1 },
|
||||
],
|
||||
}),
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("fallback text-detail summary-detail")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -407,6 +407,30 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes unknown OpenAI provider options through with snake-cased keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
||||
prompt: "passthrough",
|
||||
providerOptions: {
|
||||
openai: {
|
||||
customCamelCaseField: "value",
|
||||
already_snake_case: 42,
|
||||
nested: { keepCamelCase: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
custom_camel_case_field: "value",
|
||||
already_snake_case: 42,
|
||||
nested: { keepCamelCase: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("request OpenAI provider options override route defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -159,7 +159,7 @@
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "catalog:",
|
||||
"venice-ai-sdk-provider": "2.0.1",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
|
||||
@@ -2,7 +2,8 @@ import { and } from "drizzle-orm"
|
||||
import { Database } from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { ProjectTable } from "./project.sql"
|
||||
import { SessionTable } from "../session/session.sql"
|
||||
import { PermissionTable, SessionTable } from "../session/session.sql"
|
||||
import { WorkspaceTable } from "../control-plane/workspace.sql"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
@@ -12,12 +13,13 @@ import { ProjectID } from "./schema"
|
||||
import { Bus } from "@/bus"
|
||||
import { Command } from "@/command"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect"
|
||||
import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Project as ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
@@ -86,6 +88,10 @@ export function fromRow(row: Row): Info {
|
||||
}
|
||||
}
|
||||
|
||||
function mergePermissionRules<T extends readonly unknown[]>(oldRules: T, newRules: T): T {
|
||||
return [...new Map([...oldRules, ...newRules].map((rule) => [JSON.stringify(rule), rule])).values()] as unknown as T
|
||||
}
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
name: Schema.optional(Schema.String),
|
||||
@@ -132,16 +138,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
|
||||
|
||||
type GitResult = { code: number; text: string; stderr: string }
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service | RuntimeFlags.Service
|
||||
> = Layer.effect(
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const proc = yield* AppProcess.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const projectV2 = yield* ProjectV2.Service
|
||||
const bus = yield* Bus.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
@@ -175,115 +178,74 @@ export const layer: Layer.Layer<
|
||||
|
||||
const fakeVcs = Schema.decodeUnknownSync(Schema.optional(ProjectVcs))(Flag.OPENCODE_FAKE_VCS)
|
||||
|
||||
const resolveGitPath = (cwd: string, name: string) => {
|
||||
if (!name) return cwd
|
||||
name = name.replace(/[\r\n]+$/, "")
|
||||
if (!name) return cwd
|
||||
name = AppFileSystem.windowsPath(name)
|
||||
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
|
||||
return pathSvc.resolve(cwd, name)
|
||||
}
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||
Effect.map((x) => x.trim()),
|
||||
Effect.map((x) => ProjectID.make(x)),
|
||||
Effect.catch(() => Effect.void),
|
||||
const migrateProjectId = Effect.fn("Project.migrateProjectId")(function* (
|
||||
oldID: ProjectID | undefined,
|
||||
newID: ProjectID,
|
||||
) {
|
||||
if (!oldID) return
|
||||
if (oldID === ProjectID.global) return
|
||||
if (oldID === newID) return
|
||||
|
||||
yield* Effect.sync(() =>
|
||||
Database.transaction(
|
||||
(d) => {
|
||||
const oldProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get()
|
||||
const newProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get()
|
||||
if (oldProject && !newProject) {
|
||||
d.insert(ProjectTable)
|
||||
.values({
|
||||
...oldProject,
|
||||
id: newID,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
const oldPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get()
|
||||
const newPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get()
|
||||
if (oldPermission && newPermission) {
|
||||
d.update(PermissionTable)
|
||||
.set({
|
||||
data: mergePermissionRules(oldPermission.data, newPermission.data),
|
||||
time_created: Math.min(oldPermission.time_created, newPermission.time_created),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(PermissionTable.project_id, newID))
|
||||
.run()
|
||||
d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
|
||||
}
|
||||
if (oldPermission && !newPermission) {
|
||||
d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run()
|
||||
}
|
||||
|
||||
d.update(SessionTable).set({ project_id: newID }).where(eq(SessionTable.project_id, oldID)).run()
|
||||
d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run()
|
||||
|
||||
if (oldProject) d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run()
|
||||
},
|
||||
{ behavior: "immediate" },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
|
||||
log.info("fromDirectory", { directory })
|
||||
|
||||
// Phase 1: discover git info
|
||||
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
|
||||
|
||||
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
||||
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgit = dotgitMatches[0]
|
||||
|
||||
if (!dotgit) {
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: "/",
|
||||
sandbox: "/",
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
|
||||
let sandbox = pathSvc.dirname(dotgit)
|
||||
const gitBinary = yield* Effect.sync(() => which("git"))
|
||||
let id = yield* readCachedProjectId(dotgit)
|
||||
|
||||
if (!gitBinary) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
|
||||
if (commonDir.code !== 0) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
const common = resolveGitPath(sandbox, commonDir.text.trim())
|
||||
const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox })
|
||||
const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true"
|
||||
const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common)
|
||||
|
||||
if (id == null) {
|
||||
id = yield* readCachedProjectId(common)
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
|
||||
const roots = revList.text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((x) => x.trim())
|
||||
.toSorted()
|
||||
|
||||
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
||||
if (id) {
|
||||
yield* fs.writeFileString(pathSvc.join(common, "opencode"), id).pipe(Effect.ignore)
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
|
||||
}
|
||||
|
||||
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
|
||||
if (topLevel.code !== 0) {
|
||||
return {
|
||||
id,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
|
||||
|
||||
return { id, sandbox, worktree, vcs: "git" as const }
|
||||
})
|
||||
const data = yield* projectV2.resolve(AbsolutePath.make(directory))
|
||||
const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory
|
||||
|
||||
// Phase 2: upsert
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
||||
const projectID = ProjectID.make(data.id)
|
||||
yield* migrateProjectId(data.previous ? ProjectID.make(data.previous) : undefined, projectID)
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())
|
||||
const existing = row
|
||||
? fromRow(row)
|
||||
: {
|
||||
id: data.id,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs,
|
||||
id: projectID,
|
||||
worktree,
|
||||
vcs: data.vcs?.type ?? fakeVcs,
|
||||
sandboxes: [] as string[],
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
}
|
||||
@@ -292,12 +254,16 @@ export const layer: Layer.Layer<
|
||||
|
||||
const result: Info = {
|
||||
...existing,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs,
|
||||
worktree: projectID === ProjectID.global ? worktree : existing.worktree,
|
||||
vcs: data.vcs?.type ?? fakeVcs,
|
||||
time: { ...existing.time, updated: Date.now() },
|
||||
}
|
||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||
result.sandboxes.push(data.sandbox)
|
||||
if (
|
||||
projectID !== ProjectID.global &&
|
||||
data.directory !== result.worktree &&
|
||||
!result.sandboxes.includes(data.directory)
|
||||
)
|
||||
result.sandboxes.push(data.directory)
|
||||
result.sandboxes = yield* Effect.forEach(
|
||||
result.sandboxes,
|
||||
(s) =>
|
||||
@@ -343,18 +309,21 @@ export const layer: Layer.Layer<
|
||||
.run(),
|
||||
)
|
||||
|
||||
if (data.id !== ProjectID.global) {
|
||||
if (projectID !== ProjectID.global) {
|
||||
yield* db((d) =>
|
||||
d
|
||||
.update(SessionTable)
|
||||
.set({ project_id: data.id })
|
||||
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
|
||||
.set({ project_id: projectID })
|
||||
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.directory)))
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
|
||||
yield* emitUpdated(result)
|
||||
return { project: result, sandbox: data.sandbox }
|
||||
if (projectID !== ProjectID.global && data.vcs?.type === "git") {
|
||||
yield* projectV2.commit({ store: data.vcs.store, id: data.id })
|
||||
}
|
||||
return { project: result, sandbox: data.vcs ? data.directory : worktree }
|
||||
})
|
||||
|
||||
const discover = Effect.fn("Project.discover")(function* (input: Info) {
|
||||
@@ -510,9 +479,10 @@ export const layer: Layer.Layer<
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
|
||||
@@ -110,7 +110,9 @@ const messages = (input: readonly ModelMessage[]) => {
|
||||
Message.make({
|
||||
role: message.role,
|
||||
content: content(message.content),
|
||||
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
|
||||
// Message provider options are already provider-native wire metadata
|
||||
// (for example DeepSeek's reasoning_content continuation field).
|
||||
native: isRecord(message.providerOptions) ? message.providerOptions : undefined,
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
@@ -340,7 +340,7 @@ export const ShellTool = Tool.define(
|
||||
const trunc = yield* Truncate.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const defaultTimeout = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
|
||||
const defaultTimeoutMs = flags.bashDefaultTimeoutMs ?? 2 * 60 * 1000
|
||||
|
||||
const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) {
|
||||
const lines = yield* spawner
|
||||
@@ -601,7 +601,7 @@ export const ShellTool = Tool.define(
|
||||
const shell = Shell.acceptable(cfg.shell)
|
||||
const name = Shell.name(shell)
|
||||
const limits = yield* trunc.limits()
|
||||
const prompt = ShellPrompt.render(name, process.platform, limits)
|
||||
const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs)
|
||||
log.info("shell tool using shell", { shell })
|
||||
|
||||
return {
|
||||
@@ -616,7 +616,7 @@ export const ShellTool = Tool.define(
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
|
||||
}
|
||||
const timeout = params.timeout ?? defaultTimeout
|
||||
const timeout = params.timeout ?? defaultTimeoutMs
|
||||
const ps = Shell.ps(shell)
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -83,7 +83,7 @@ function chainGuidance(name: string) {
|
||||
return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
}
|
||||
|
||||
function bashCommandSection(chain: string, limits: Limits) {
|
||||
function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) {
|
||||
return `Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
@@ -102,7 +102,7 @@ function bashCommandSection(chain: string, limits: Limits) {
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
@@ -127,7 +127,13 @@ Usage notes:
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) {
|
||||
function powershellCommandSection(
|
||||
name: string,
|
||||
chain: string,
|
||||
pathSep: string,
|
||||
limits: Limits,
|
||||
defaultTimeoutMs: number,
|
||||
) {
|
||||
return `${powershellNotes(name)}
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
@@ -148,7 +154,7 @@ Before executing the command, please follow these steps:
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
@@ -173,7 +179,7 @@ Usage notes:
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function cmdCommandSection(chain: string, limits: Limits) {
|
||||
function cmdCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) {
|
||||
return `# cmd.exe shell notes
|
||||
- Use double quotes for paths with spaces.
|
||||
- Use %VAR% for environment variables.
|
||||
@@ -198,7 +204,7 @@ Before executing the command, please follow these steps:
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
@@ -223,7 +229,7 @@ Usage notes:
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function profile(name: string, platform: NodeJS.Platform, limits: Limits) {
|
||||
function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) {
|
||||
const isPowerShell = PS.has(name)
|
||||
const chain = chainGuidance(name)
|
||||
if (CMD.has(name)) {
|
||||
@@ -231,7 +237,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits) {
|
||||
intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`,
|
||||
workdirSection:
|
||||
"All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.",
|
||||
commandSection: cmdCommandSection(chain, limits),
|
||||
commandSection: cmdCommandSection(chain, limits, defaultTimeoutMs),
|
||||
gitCommands: "git commands",
|
||||
gitCommandRestriction: "git commands",
|
||||
createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.",
|
||||
@@ -244,7 +250,13 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits) {
|
||||
intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`,
|
||||
workdirSection:
|
||||
"All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.",
|
||||
commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits),
|
||||
commandSection: powershellCommandSection(
|
||||
name,
|
||||
chain,
|
||||
platform === "win32" ? "\\" : "/",
|
||||
limits,
|
||||
defaultTimeoutMs,
|
||||
),
|
||||
gitCommands: "git commands",
|
||||
gitCommandRestriction: "git commands",
|
||||
createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.",
|
||||
@@ -260,7 +272,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits) {
|
||||
"Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.",
|
||||
workdirSection:
|
||||
"All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd <directory> && <command>` patterns - use `workdir` instead.",
|
||||
commandSection: bashCommandSection(chain, limits),
|
||||
commandSection: bashCommandSection(chain, limits, defaultTimeoutMs),
|
||||
gitCommands: "bash commands",
|
||||
gitCommandRestriction: "git bash commands",
|
||||
createPrInstruction:
|
||||
@@ -272,8 +284,8 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits) {
|
||||
}
|
||||
}
|
||||
|
||||
export function render(name: string, platform: NodeJS.Platform, limits: Limits) {
|
||||
const selected = profile(name, platform, limits)
|
||||
export function render(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) {
|
||||
const selected = profile(name, platform, limits, defaultTimeoutMs)
|
||||
return {
|
||||
description: renderPrompt(DESCRIPTION, {
|
||||
intro: selected.intro,
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
@@ -35,7 +35,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -7,10 +7,21 @@ import path from "path"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Database } from "@/storage/db"
|
||||
import { ProjectTable } from "@/project/project.sql"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { PermissionTable } from "@/session/session.sql"
|
||||
import { WorkspaceTable } from "@/control-plane/workspace.sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Cause, Effect, Exit, Layer, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Project as ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
@@ -29,6 +40,10 @@ function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
|
||||
})
|
||||
}
|
||||
|
||||
function remoteProjectID(remote: string) {
|
||||
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
|
||||
* matching `failArg` and returns exit code 128, while delegating everything
|
||||
@@ -66,7 +81,9 @@ function mockGitFailure(failArg: string) {
|
||||
|
||||
function projectLayerWithFailure(failArg: string) {
|
||||
return Project.layer.pipe(
|
||||
Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
|
||||
Layer.provide(mockGitFailure(failArg)),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
@@ -77,6 +94,8 @@ function projectLayerWithFailure(failArg: string) {
|
||||
function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.layer>[0]) {
|
||||
return Project.layer.pipe(
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
@@ -128,9 +147,6 @@ describe("Project.fromDirectory", () => {
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.worktree).toBe(tmp)
|
||||
|
||||
const opencodeFile = path.join(tmp, ".git", "opencode")
|
||||
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -150,6 +166,94 @@ describe("Project.fromDirectory", () => {
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers normalized origin remote over root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
|
||||
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("normalizes equivalent origin URL forms to the same project ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const ssh = yield* tmpdirScoped({ git: true })
|
||||
const https = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
|
||||
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
|
||||
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
|
||||
|
||||
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("migrates cached root project data when origin becomes available", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project: rootProject } = yield* projects.fromDirectory(tmp)
|
||||
const remoteID = remoteProjectID("github.com/acme/app")
|
||||
const sessionID = crypto.randomUUID() as SessionID
|
||||
const workspaceID = WorkspaceID.ascending()
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
Database.use((db) => {
|
||||
db.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: rootProject.id,
|
||||
slug: sessionID,
|
||||
directory: tmp,
|
||||
title: "test",
|
||||
version: "0.0.0-test",
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(PermissionTable)
|
||||
.values({
|
||||
project_id: rootProject.id,
|
||||
data: [{ permission: "edit", pattern: "*", action: "allow" }],
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: workspaceID,
|
||||
type: "local",
|
||||
name: "test",
|
||||
project_id: rootProject.id,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
})
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
|
||||
expect(project.id).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get()),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())?.project_id,
|
||||
).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get()),
|
||||
).toBeDefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get())
|
||||
?.project_id,
|
||||
).toBe(remoteID)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Project.fromDirectory git failure paths", () => {
|
||||
@@ -200,7 +304,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("should set worktree to root when called from a worktree", () =>
|
||||
it.live("tracks a linked worktree as the opened project directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
@@ -217,9 +321,9 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
expect(sandbox).toBe(worktreePath)
|
||||
expect(project.sandboxes).toContain(worktreePath)
|
||||
expect(project.sandboxes).not.toContain(worktreePath)
|
||||
expect(project.sandboxes).not.toContain(tmp)
|
||||
}),
|
||||
)
|
||||
@@ -245,7 +349,6 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
|
||||
expect(wt.id).toBe(main.id)
|
||||
|
||||
// Cache should live in the common .git dir, not the worktree's .git file
|
||||
const cache = path.join(tmp, ".git", "opencode")
|
||||
const exists = yield* Effect.promise(() => Bun.file(cache).exists())
|
||||
expect(exists).toBe(true)
|
||||
@@ -300,8 +403,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
yield* run((svc) => svc.fromDirectory(worktree1))
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktree2))
|
||||
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(project.sandboxes).toContain(worktree1)
|
||||
expect(project.worktree).toBe(worktree1)
|
||||
expect(project.sandboxes).toContain(worktree2)
|
||||
expect(project.sandboxes).not.toContain(tmp)
|
||||
}),
|
||||
@@ -640,7 +742,7 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(barePath)
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
|
||||
const correctCache = path.join(barePath, "opencode")
|
||||
const wrongCache = path.join(parentDir, ".git", "opencode")
|
||||
@@ -703,7 +805,7 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(barePath)
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
|
||||
const correctCache = path.join(barePath, "opencode")
|
||||
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Effect, Layer, Stream } from "effect"
|
||||
import { LLMNative } from "@/session/llm/native-request"
|
||||
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { OAUTH_DUMMY_KEY } from "@/auth"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -70,6 +71,21 @@ const providerInfo: Provider.Info = {
|
||||
models: {},
|
||||
}
|
||||
|
||||
const compatibleModel: Provider.Model = {
|
||||
...baseModel,
|
||||
id: ModelID.make("deepseek-v4-flash-free"),
|
||||
providerID: ProviderID.make("opencode"),
|
||||
api: {
|
||||
id: "deepseek-v4-flash-free",
|
||||
url: "https://ai.example.test/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
capabilities: {
|
||||
...baseModel.capabilities,
|
||||
interleaved: { field: "reasoning_content" },
|
||||
},
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))),
|
||||
)
|
||||
@@ -326,6 +342,70 @@ describe("session.llm-native.request", () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.effect("preserves OpenAI-compatible reasoning continuation and provider options", () =>
|
||||
Effect.gen(function* () {
|
||||
const messages = ProviderTransform.message(
|
||||
[
|
||||
{ role: "user", content: "Audit the site" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "I should inspect the page." },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "devtools_new_page",
|
||||
input: { url: "https://example.test" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as ModelMessage[],
|
||||
compatibleModel,
|
||||
{},
|
||||
)
|
||||
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLMNative.request({
|
||||
model: compatibleModel,
|
||||
apiKey: "test-key",
|
||||
messages,
|
||||
providerOptions: ProviderTransform.providerOptions(compatibleModel, {
|
||||
reasoningEffort: "max",
|
||||
textVerbosity: "low",
|
||||
promptCacheKey: "session-1",
|
||||
enable_thinking: true,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "deepseek-v4-flash-free",
|
||||
reasoning_effort: "max",
|
||||
verbosity: "low",
|
||||
prompt_cache_key: "session-1",
|
||||
enable_thinking: true,
|
||||
messages: [
|
||||
{ role: "user", content: "Audit the site" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning_content: "I should inspect the page.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "devtools_new_page",
|
||||
arguments: '{"url":"https://example.test"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("selects native request routes for provider packages", () => {
|
||||
const openai = LLMNative.model({
|
||||
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
|
||||
|
||||
@@ -1085,10 +1085,15 @@ describe("tool.shell abort", () => {
|
||||
runIn(
|
||||
projectRoot,
|
||||
Effect.gen(function* () {
|
||||
const result = yield* run({
|
||||
command: `echo started && sleep 60`,
|
||||
description: "Default timeout test",
|
||||
})
|
||||
const tool = yield* initShell()
|
||||
expect(tool.description).toContain("commands will time out after 500ms")
|
||||
const result = yield* tool.execute(
|
||||
{
|
||||
command: `echo started && sleep 60`,
|
||||
description: "Default timeout test",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.output).toContain("started")
|
||||
expect(result.output).toContain("exceeding timeout 500 ms")
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -137,8 +137,8 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
يستخدم [model id](/docs/config/#models) في إعدادات OpenCode لديك التنسيق `opencode-go/<model-id>`. على سبيل المثال، بالنسبة إلى Kimi K2.6، ستستخدم `opencode-go/kimi-k2.6` في إعداداتك.
|
||||
|
||||
|
||||
@@ -84,8 +84,8 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ Također možete pristupiti Go modelima putem sljedećih API endpointa.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
[Model id](/docs/config/#models) u vašoj OpenCode konfiguraciji
|
||||
koristi format `opencode-go/<model-id>`. Na primjer, za Kimi K2.6, koristili biste
|
||||
|
||||
@@ -89,8 +89,8 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ Du kan også få adgang til Go-modeller gennem følgende API-endpoints.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
Dit [model id](/docs/config/#models) i din OpenCode config
|
||||
bruger formatet `opencode-go/<model-id>`. For eksempel for Kimi K2.6, vil du
|
||||
|
||||
@@ -89,8 +89,8 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -139,8 +139,8 @@ Du kannst auf die Go-Modelle auch über die folgenden API-Endpunkte zugreifen.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
Die [Modell-ID](/docs/config/#models) in deiner OpenCode Config verwendet das Format `opencode-go/<model-id>`. Für Kimi K2.6 würdest du beispielsweise `opencode-go/kimi-k2.6` in deiner Config verwenden.
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ También puedes acceder a los modelos de Go a través de los siguientes endpoint
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
El [ID del modelo](/docs/config/#models) en tu configuración de OpenCode
|
||||
usa el formato `opencode-go/<model-id>`. Por ejemplo, para Kimi K2.6, usarías
|
||||
|
||||
@@ -89,8 +89,8 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ Vous pouvez également accéder aux modèles Go via les points de terminaison d'
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
L'[ID de modèle](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode-go/<model-id>`. Par exemple, pour Kimi K2.6, vous utiliseriez `opencode-go/kimi-k2.6` dans votre configuration.
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ You can also access Go models through the following API endpoints.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
The [model id](/docs/config/#models) in your OpenCode config
|
||||
uses the format `opencode-go/<model-id>`. For example, for Kimi K2.6, you would
|
||||
|
||||
@@ -147,8 +147,8 @@ Puoi anche accedere ai modelli Go tramite i seguenti endpoint API.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
Il [model id](/docs/config/#models) nella tua OpenCode config
|
||||
utilizza il formato `opencode-go/<model-id>`. Ad esempio, per Kimi K2.6, useresti
|
||||
|
||||
@@ -89,8 +89,8 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ Zen残高にクレジットがある場合は、コンソールで**Use balance*
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
OpenCode設定の[model id](/docs/config/#models)は、`opencode-go/<model-id>`という形式を使用します。たとえば、Kimi K2.6の場合は、設定で`opencode-go/kimi-k2.6`を使用します。
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ Zen 잔액에 크레딧도 있다면, console에서 **Use balance** 옵션을
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
OpenCode config의 [model id](/docs/config/#models)는 `opencode-go/<model-id>` 형식을 사용합니다. 예를 들어 Kimi K2.6의 경우 config에서 `opencode-go/kimi-k2.6`를 사용하면 됩니다.
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ Du kan også få tilgang til Go-modeller gjennom følgende API-endepunkter.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
[Modell-ID-en](/docs/config/#models) i din OpenCode-konfigurasjon
|
||||
bruker formatet `opencode-go/<model-id>`. For eksempel, for Kimi K2.6, vil du
|
||||
|
||||
@@ -89,8 +89,8 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -141,8 +141,8 @@ Możesz również uzyskać dostęp do modeli Go za pośrednictwem następującyc
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
[ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode
|
||||
używa formatu `opencode-go/<model-id>`. Na przykład dla Kimi K2.6 należy użyć
|
||||
|
||||
@@ -89,8 +89,8 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ Você também pode acessar os modelos do Go através dos seguintes endpoints de
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
O [ID do modelo](/docs/config/#models) na sua configuração do OpenCode
|
||||
usa o formato `opencode-go/<model-id>`. Por exemplo, para o Kimi K2.6, você usaria
|
||||
|
||||
@@ -80,8 +80,8 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -149,8 +149,8 @@ OpenCode Go включает следующие лимиты:
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
[ID модели](/docs/config/#models) в вашем конфиге OpenCode
|
||||
использует формат `opencode-go/<model-id>`. Например, для Kimi K2.6 вам нужно
|
||||
|
||||
@@ -89,8 +89,8 @@ OpenCode Zen работает как любой другой провайдер
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้:
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
[model id](/docs/config/#models) ใน OpenCode config ของคุณจะใช้รูปแบบ `opencode-go/<model-id>` ตัวอย่างเช่น สำหรับ Kimi K2.6 คุณจะใช้ `opencode-go/kimi-k2.6` ใน config ของคุณ
|
||||
|
||||
|
||||
@@ -82,8 +82,8 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ Go modellerine aşağıdaki API uç noktaları aracılığıyla da erişebilirsi
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
OpenCode yapılandırmanızdaki [model id](/docs/config/#models) formatı `opencode-go/<model-id>` şeklindedir. Örneğin, Kimi K2.6 için yapılandırmanızda `opencode-go/kimi-k2.6` kullanmalısınız.
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -89,8 +89,8 @@ You can also access our models through the following API endpoints.
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ OpenCode Go 包含以下限制:
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
你 OpenCode 配置中的 [模型 ID](/docs/config/#models) 使用 `opencode-go/<model-id>` 格式。例如,对于 Kimi K2.6,你将在配置中使用 `opencode-go/kimi-k2.6`。
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -137,8 +137,8 @@ OpenCode Go 包含以下限制:
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
|
||||
您的 OpenCode 設定中的 [model id](/docs/config/#models) 使用 `opencode-go/<model-id>` 格式。例如,Kimi K2.6 在設定中應使用 `opencode-go/kimi-k2.6`。
|
||||
|
||||
|
||||
@@ -84,8 +84,8 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。
|
||||
| Gemini 3.5 Flash | gemini-3.5-flash | `https://opencode.ai/zen/v1/models/gemini-3.5-flash` | `@ai-sdk/google` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5.1 | glm-5.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.15.9",
|
||||
"version": "1.15.10",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user