mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd79676b42 | |||
| 09e7e0ab70 | |||
| 0e60f66604 | |||
| fc8db6cdf9 | |||
| 5cc37c4ea0 | |||
| 46ad456718 | |||
| 832ffd2303 | |||
| b261430880 | |||
| 545f345848 | |||
| 77ae0b527e | |||
| c1278109c9 | |||
| a7a88d01ef | |||
| 4e0ab6b634 | |||
| d36485b7af | |||
| 1da24f6adb | |||
| e29dd27632 | |||
| 37380e1f94 | |||
| 1309ca7a81 | |||
| c1515316f5 | |||
| b66e7b6fce | |||
| eb398f1951 | |||
| 643c22d21f | |||
| 74acd08ead | |||
| 49ea5aa2ad | |||
| ee1af0fe80 | |||
| dfebf40471 | |||
| 6af6a1295f | |||
| 22821744ef | |||
| 872c9467b2 | |||
| d8249f32a8 | |||
| 982954cc1b | |||
| 4caa458232 | |||
| 6fe8e3973c | |||
| 7816901713 | |||
| 71abca9571 | |||
| 7216a8c86d | |||
| e3e16e58c5 | |||
| a2951a2702 | |||
| 55453dc606 | |||
| 198d7f7e5f | |||
| e3e9fd7aa8 | |||
| 3c56dbcf58 | |||
| ee07ed2dc4 | |||
| 485e4520e7 | |||
| fc115ea367 | |||
| d03b79e61e | |||
| 0acae8211a | |||
| 0af4505756 | |||
| a606e1d2ec | |||
| 0e65700183 | |||
| e6301ca5d5 | |||
| b562863fcc | |||
| db85f01eff | |||
| 1a6fd018f6 | |||
| fdb5bae3c6 | |||
| a9624c0fff | |||
| 316d4c9197 | |||
| 5e886c35d5 | |||
| 5162268f9d | |||
| 0eb899a950 | |||
| 3241f6b8bb | |||
| 2c792f17e6 | |||
| 7d0c6860cd | |||
| c70e393c81 |
@@ -0,0 +1,71 @@
|
||||
#
|
||||
# This file is intentionally in the wrong dir, will move and add later....
|
||||
#
|
||||
|
||||
# name: publish-python-sdk
|
||||
|
||||
# on:
|
||||
# release:
|
||||
# types: [published]
|
||||
# workflow_dispatch:
|
||||
|
||||
# jobs:
|
||||
# publish:
|
||||
# runs-on: ubuntu-latest
|
||||
# permissions:
|
||||
# contents: read
|
||||
# steps:
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@v4
|
||||
|
||||
# - name: Setup Bun
|
||||
# uses: oven-sh/setup-bun@v1
|
||||
# with:
|
||||
# bun-version: 1.2.21
|
||||
|
||||
# - name: Install dependencies (JS/Bun)
|
||||
# run: bun install
|
||||
|
||||
# - name: Install uv
|
||||
# shell: bash
|
||||
# run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# - name: Generate Python SDK from OpenAPI (CLI)
|
||||
# shell: bash
|
||||
# run: |
|
||||
# ~/.local/bin/uv run --project packages/sdk/python python packages/sdk/python/scripts/generate.py --source cli
|
||||
|
||||
# - name: Sync Python dependencies
|
||||
# shell: bash
|
||||
# run: |
|
||||
# ~/.local/bin/uv sync --dev --project packages/sdk/python
|
||||
|
||||
# - name: Set version from release tag
|
||||
# shell: bash
|
||||
# run: |
|
||||
# TAG="${GITHUB_REF_NAME:-}"
|
||||
# if [ -z "$TAG" ]; then
|
||||
# TAG="$(git describe --tags --abbrev=0 || echo 0.0.0)"
|
||||
# fi
|
||||
# echo "Using version: $TAG"
|
||||
# VERSION="$TAG" ~/.local/bin/uv run --project packages/sdk/python python - <<'PY'
|
||||
# import os, re, pathlib
|
||||
# root = pathlib.Path('packages/sdk/python')
|
||||
# pt = (root / 'pyproject.toml').read_text()
|
||||
# version = os.environ.get('VERSION','0.0.0').lstrip('v')
|
||||
# pt = re.sub(r'(?m)^(version\s*=\s*")[^"]+("\s*)$', f"\\1{version}\\2", pt)
|
||||
# (root / 'pyproject.toml').write_text(pt)
|
||||
# # Also update generator config override for consistency
|
||||
# cfgp = root / 'openapi-python-client.yaml'
|
||||
# if cfgp.exists():
|
||||
# cfg = cfgp.read_text()
|
||||
# cfg = re.sub(r'(?m)^(package_version_override:\s*)\S+$', f"\\1{version}", cfg)
|
||||
# cfgp.write_text(cfg)
|
||||
# PY
|
||||
|
||||
# - name: Build and publish to PyPI
|
||||
# env:
|
||||
# PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||
# shell: bash
|
||||
# run: |
|
||||
# ~/.local/bin/uv run --project packages/sdk/python python packages/sdk/python/scripts/publish.py
|
||||
@@ -119,3 +119,6 @@
|
||||
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
|
||||
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
|
||||
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
|
||||
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
|
||||
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
|
||||
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -141,6 +141,7 @@
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"opencode": "workspace:*",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -149,7 +150,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -165,7 +166,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -229,7 +230,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -249,7 +250,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.81.0",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -260,7 +261,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -273,10 +274,10 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
|
||||
"@pierre/precision-diffs": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
@@ -296,7 +297,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -343,7 +344,7 @@
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
|
||||
"@pierre/precision-diffs": "0.3.6",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
@@ -937,7 +938,7 @@
|
||||
|
||||
"@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="],
|
||||
|
||||
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.0.2-alpha.1-1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" } }, "sha512-T43cwB7gMnbM+tp9p73NptUm4uUOfmrP5ihMOAHWQPpzBa/oeTjqZlmEmSQLpT8WKKnWG0lbKZPtlw7l0gW0Vw=="],
|
||||
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.3.6", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-cKM3HcMmyr5wPFll0bHYcgHplcHgMlL6Dw4Pi4giL0jVt7ySlGwwVyXTRFW5Fva43stOL+EWB+9U5VBDSktBJA=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
|
||||
+2
-2
@@ -104,7 +104,7 @@ To test locally:
|
||||
- `MODEL`: The model used by opencode. Same as the `MODEL` defined in the GitHub workflow.
|
||||
- `ANTHROPIC_API_KEY`: Your model provider API key. Same as the keys defined in the GitHub workflow.
|
||||
- `GITHUB_RUN_ID`: Dummy value to emulate GitHub action environment.
|
||||
- `MOCK_TOKEN`: A GitHub persontal access token. This token is used to verify you have `admin` or `write` access to the test repo. Generate a token [here](https://github.com/settings/personal-access-tokens).
|
||||
- `MOCK_TOKEN`: A GitHub personal access token. This token is used to verify you have `admin` or `write` access to the test repo. Generate a token [here](https://github.com/settings/personal-access-tokens).
|
||||
- `MOCK_EVENT`: Mock GitHub event payload (see templates below).
|
||||
- `/path/to/opencode`: Path to your cloned opencode repo. `bun /path/to/opencode/github/index.ts` runs your local version of `opencode`.
|
||||
|
||||
@@ -118,7 +118,7 @@ Replace:
|
||||
|
||||
- `"owner":"sst"` with repo owner
|
||||
- `"repo":"hello-world"` with repo name
|
||||
- `"actor":"fwang"` with the GitHub username of commentor
|
||||
- `"actor":"fwang"` with the GitHub username of commenter
|
||||
- `"number":4` with the GitHub issue id
|
||||
- `"body":"hey opencode, summarize thread"` with comment body
|
||||
|
||||
|
||||
+4
-4
@@ -25,7 +25,7 @@
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/precision-diffs": "0.0.2-alpha.1-1",
|
||||
"@pierre/precision-diffs": "0.3.6",
|
||||
"@solidjs/meta": "0.29.4",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
@@ -53,8 +53,8 @@
|
||||
"turbo": "2.5.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*"
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -63,7 +63,7 @@
|
||||
"license": "MIT",
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 120
|
||||
"printWidth": 100
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vinxi start",
|
||||
"version": "0.15.18"
|
||||
"version": "0.15.20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export class AuthError extends Error {}
|
||||
export class CreditsError extends Error {}
|
||||
export class MonthlyLimitError extends Error {}
|
||||
export class UserLimitError extends Error {}
|
||||
export class ModelError extends Error {}
|
||||
@@ -0,0 +1 @@
|
||||
export type Format = "anthropic" | "openai" | "oa-compat"
|
||||
+65
-60
@@ -1,67 +1,41 @@
|
||||
import { z } from "zod"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import path from "node:path"
|
||||
import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { BillingTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||
import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { Identifier } from "@opencode-ai/console-core/identifier.js"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Billing } from "../../../../core/src/billing"
|
||||
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
|
||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
|
||||
import { logger } from "./logger"
|
||||
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError } from "./error"
|
||||
import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
|
||||
import { Format } from "./format"
|
||||
import { anthropicHelper } from "./provider/anthropic"
|
||||
import { openaiHelper } from "./provider/openai"
|
||||
import { oaCompatHelper } from "./provider/openai-compatible"
|
||||
|
||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||
type Model = ZenData["models"][string]
|
||||
|
||||
export async function handler(
|
||||
input: APIEvent,
|
||||
opts: {
|
||||
modifyBody?: (body: any) => any
|
||||
setAuthHeader: (headers: Headers, apiKey: string) => void
|
||||
format: Format
|
||||
parseApiKey: (headers: Headers) => string | undefined
|
||||
onStreamPart: (chunk: string) => void
|
||||
getStreamUsage: () => any
|
||||
normalizeUsage: (body: any) => {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWrite5mTokens?: number
|
||||
cacheWrite1hTokens?: number
|
||||
}
|
||||
},
|
||||
) {
|
||||
class AuthError extends Error {}
|
||||
class CreditsError extends Error {}
|
||||
class MonthlyLimitError extends Error {}
|
||||
class UserLimitError extends Error {}
|
||||
class ModelError extends Error {}
|
||||
|
||||
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
||||
type Model = ZenData["models"][string]
|
||||
|
||||
const FREE_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
||||
]
|
||||
|
||||
const logger = {
|
||||
metric: (values: Record<string, any>) => {
|
||||
console.log(`_metric:${JSON.stringify(values)}`)
|
||||
},
|
||||
log: console.log,
|
||||
debug: (message: string) => {
|
||||
if (Resource.App.stage === "production") return
|
||||
console.debug(message)
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input.request.url)
|
||||
const body = await input.request.json()
|
||||
logger.debug(JSON.stringify(body))
|
||||
logger.metric({
|
||||
is_tream: !!body.stream,
|
||||
session: input.request.headers.get("x-opencode-session"),
|
||||
@@ -69,7 +43,7 @@ export async function handler(
|
||||
})
|
||||
const zenData = ZenData.list()
|
||||
const modelInfo = validateModel(zenData, body.model)
|
||||
const providerInfo = selectProvider(zenData, modelInfo)
|
||||
const providerInfo = selectProvider(zenData, modelInfo, input.request.headers.get("x-real-ip") ?? "")
|
||||
const authInfo = await authenticate(modelInfo, providerInfo)
|
||||
validateBilling(modelInfo, authInfo)
|
||||
validateModelSettings(authInfo)
|
||||
@@ -78,22 +52,28 @@ export async function handler(
|
||||
|
||||
// Request to model provider
|
||||
const startTimestamp = Date.now()
|
||||
const res = await fetch(path.posix.join(providerInfo.api, url.pathname.replace(/^\/zen\/v1/, "") + url.search), {
|
||||
const reqUrl = providerInfo.modifyUrl(providerInfo.api)
|
||||
const reqBody = JSON.stringify(
|
||||
providerInfo.modifyBody({
|
||||
...createBodyConverter(opts.format, providerInfo.format)(body),
|
||||
model: providerInfo.model,
|
||||
}),
|
||||
)
|
||||
logger.debug("REQUEST URL: " + reqUrl)
|
||||
logger.debug("REQUEST: " + reqBody)
|
||||
const res = await fetch(reqUrl, {
|
||||
method: "POST",
|
||||
headers: (() => {
|
||||
const headers = input.request.headers
|
||||
headers.delete("host")
|
||||
headers.delete("content-length")
|
||||
opts.setAuthHeader(headers, providerInfo.apiKey)
|
||||
providerInfo.modifyHeaders(headers, body, providerInfo.apiKey)
|
||||
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
||||
headers.set(k, headers.get(v)!)
|
||||
})
|
||||
return headers
|
||||
})(),
|
||||
body: JSON.stringify({
|
||||
...(opts.modifyBody?.(body) ?? body),
|
||||
model: providerInfo.model,
|
||||
}),
|
||||
body: reqBody,
|
||||
})
|
||||
|
||||
// Scrub response headers
|
||||
@@ -104,14 +84,19 @@ export async function handler(
|
||||
resHeaders.set(k, v)
|
||||
}
|
||||
}
|
||||
logger.debug("STATUS: " + res.status + " " + res.statusText)
|
||||
if (res.status === 400 || res.status === 503) {
|
||||
logger.debug("RESPONSE: " + (await res.text()))
|
||||
}
|
||||
|
||||
// Handle non-streaming response
|
||||
if (!body.stream) {
|
||||
const responseConverter = createResponseConverter(providerInfo.format, opts.format)
|
||||
const json = await res.json()
|
||||
const body = JSON.stringify(json)
|
||||
const body = JSON.stringify(responseConverter(json))
|
||||
logger.metric({ response_length: body.length })
|
||||
logger.debug(body)
|
||||
await trackUsage(authInfo, modelInfo, providerInfo.id, json.usage)
|
||||
logger.debug("RESPONSE: " + body)
|
||||
await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
|
||||
await reload(authInfo)
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
@@ -121,10 +106,13 @@ export async function handler(
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
|
||||
const usageParser = providerInfo.createUsageParser()
|
||||
const stream = new ReadableStream({
|
||||
start(c) {
|
||||
const reader = res.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const encoder = new TextEncoder()
|
||||
let buffer = ""
|
||||
let responseLength = 0
|
||||
|
||||
@@ -136,9 +124,9 @@ export async function handler(
|
||||
response_length: responseLength,
|
||||
"timestamp.last_byte": Date.now(),
|
||||
})
|
||||
const usage = opts.getStreamUsage()
|
||||
const usage = usageParser.retrieve()
|
||||
if (usage) {
|
||||
await trackUsage(authInfo, modelInfo, providerInfo.id, usage)
|
||||
await trackUsage(authInfo, modelInfo, providerInfo, usage)
|
||||
await reload(authInfo)
|
||||
}
|
||||
c.close()
|
||||
@@ -158,12 +146,21 @@ export async function handler(
|
||||
const parts = buffer.split("\n\n")
|
||||
buffer = parts.pop() ?? ""
|
||||
|
||||
for (const part of parts) {
|
||||
logger.debug(part)
|
||||
opts.onStreamPart(part.trim())
|
||||
for (let part of parts) {
|
||||
logger.debug("PART: " + part)
|
||||
|
||||
part = part.trim()
|
||||
usageParser.parse(part)
|
||||
|
||||
if (providerInfo.format !== opts.format) {
|
||||
part = streamConverter(part)
|
||||
c.enqueue(encoder.encode(part + "\n\n"))
|
||||
}
|
||||
}
|
||||
|
||||
c.enqueue(value)
|
||||
if (providerInfo.format === opts.format) {
|
||||
c.enqueue(value)
|
||||
}
|
||||
|
||||
return pump()
|
||||
}) || Promise.resolve()
|
||||
@@ -225,17 +222,25 @@ export async function handler(
|
||||
return { id: modelId, ...modelData }
|
||||
}
|
||||
|
||||
function selectProvider(zenData: ZenData, model: Awaited<ReturnType<typeof validateModel>>) {
|
||||
function selectProvider(zenData: ZenData, model: Awaited<ReturnType<typeof validateModel>>, ip: string) {
|
||||
const providers = model.providers
|
||||
.filter((provider) => !provider.disabled)
|
||||
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
||||
const provider = providers[Math.floor(Math.random() * providers.length)]
|
||||
|
||||
// Use last character of IP address to select a provider
|
||||
const lastChar = ip.charCodeAt(ip.length - 1) || 0
|
||||
const index = lastChar % providers.length
|
||||
const provider = providers[index]
|
||||
|
||||
if (!(provider.id in zenData.providers)) {
|
||||
throw new ModelError(`Provider ${provider.id} not supported`)
|
||||
}
|
||||
|
||||
return { ...provider, ...zenData.providers[provider.id] }
|
||||
return {
|
||||
...provider,
|
||||
...zenData.providers[provider.id],
|
||||
...(provider.id === "anthropic" ? anthropicHelper : provider.id === "openai" ? openaiHelper : oaCompatHelper),
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
@@ -356,11 +361,11 @@ export async function handler(
|
||||
async function trackUsage(
|
||||
authInfo: Awaited<ReturnType<typeof authenticate>>,
|
||||
modelInfo: ReturnType<typeof validateModel>,
|
||||
providerId: string,
|
||||
providerInfo: Awaited<ReturnType<typeof selectProvider>>,
|
||||
usage: any,
|
||||
) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
opts.normalizeUsage(usage)
|
||||
providerInfo.normalizeUsage(usage)
|
||||
|
||||
const modelCost =
|
||||
modelInfo.cost200K &&
|
||||
@@ -421,7 +426,7 @@ export async function handler(
|
||||
workspaceID: authInfo.workspaceID,
|
||||
id: Identifier.create("usage"),
|
||||
model: modelInfo.id,
|
||||
provider: providerId,
|
||||
provider: providerInfo.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
export const logger = {
|
||||
metric: (values: Record<string, any>) => {
|
||||
console.log(`_metric:${JSON.stringify(values)}`)
|
||||
},
|
||||
log: console.log,
|
||||
debug: (message: string) => {
|
||||
if (Resource.App.stage === "production") return
|
||||
console.debug(message)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
|
||||
|
||||
type Usage = {
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
server_tool_use?: {
|
||||
web_search_requests?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const anthropicHelper = {
|
||||
format: "anthropic",
|
||||
modifyUrl: (providerApi: string) => providerApi + "/messages",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
headers.set("x-api-key", apiKey)
|
||||
headers.set("anthropic-version", headers.get("anthropic-version") ?? "2023-06-01")
|
||||
if (body.model.startsWith("claude-sonnet-")) {
|
||||
headers.set("anthropic-beta", "context-1m-2025-08-07")
|
||||
}
|
||||
},
|
||||
modifyBody: (body: Record<string, any>) => {
|
||||
return {
|
||||
...body,
|
||||
service_tier: "standard_only",
|
||||
}
|
||||
},
|
||||
createUsageParser: () => {
|
||||
let usage: Usage
|
||||
|
||||
return {
|
||||
parse: (chunk: string) => {
|
||||
const data = chunk.split("\n")[1]
|
||||
if (!data.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6))
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
const usageUpdate = json.usage ?? json.message?.usage
|
||||
if (!usageUpdate) return
|
||||
usage = {
|
||||
...usage,
|
||||
...usageUpdate,
|
||||
cache_creation: {
|
||||
...usage?.cache_creation,
|
||||
...usageUpdate.cache_creation,
|
||||
},
|
||||
server_tool_use: {
|
||||
...usage?.server_tool_use,
|
||||
...usageUpdate.server_tool_use,
|
||||
},
|
||||
}
|
||||
},
|
||||
retrieve: () => usage,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => ({
|
||||
inputTokens: usage.input_tokens ?? 0,
|
||||
outputTokens: usage.output_tokens ?? 0,
|
||||
reasoningTokens: undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
|
||||
cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
|
||||
cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
|
||||
}),
|
||||
} satisfies ProviderHelper
|
||||
|
||||
export function fromAnthropicRequest(body: any): CommonRequest {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const msgs: any[] = []
|
||||
|
||||
const sys = Array.isArray(body.system) ? body.system : undefined
|
||||
if (sys && sys.length > 0) {
|
||||
for (const s of sys) {
|
||||
if (!s) continue
|
||||
if ((s as any).type !== "text") continue
|
||||
if (typeof (s as any).text !== "string") continue
|
||||
if ((s as any).text.length === 0) continue
|
||||
msgs.push({ role: "system", content: (s as any).text })
|
||||
}
|
||||
}
|
||||
|
||||
const toImg = (src: any) => {
|
||||
if (!src || typeof src !== "object") return undefined
|
||||
if ((src as any).type === "url" && typeof (src as any).url === "string")
|
||||
return { type: "image_url", image_url: { url: (src as any).url } }
|
||||
if (
|
||||
(src as any).type === "base64" &&
|
||||
typeof (src as any).media_type === "string" &&
|
||||
typeof (src as any).data === "string"
|
||||
)
|
||||
return { type: "image_url", image_url: { url: `data:${(src as any).media_type};base64,${(src as any).data}` } }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const inMsgs = Array.isArray(body.messages) ? body.messages : []
|
||||
for (const m of inMsgs) {
|
||||
if (!m || !(m as any).role) continue
|
||||
|
||||
if ((m as any).role === "user") {
|
||||
const partsIn = Array.isArray((m as any).content) ? (m as any).content : []
|
||||
const partsOut: any[] = []
|
||||
for (const p of partsIn) {
|
||||
if (!p || !(p as any).type) continue
|
||||
if ((p as any).type === "text" && typeof (p as any).text === "string")
|
||||
partsOut.push({ type: "text", text: (p as any).text })
|
||||
if ((p as any).type === "image") {
|
||||
const ip = toImg((p as any).source)
|
||||
if (ip) partsOut.push(ip)
|
||||
}
|
||||
if ((p as any).type === "tool_result") {
|
||||
const id = (p as any).tool_use_id
|
||||
const content =
|
||||
typeof (p as any).content === "string" ? (p as any).content : JSON.stringify((p as any).content)
|
||||
msgs.push({ role: "tool", tool_call_id: id, content })
|
||||
}
|
||||
}
|
||||
if (partsOut.length > 0) {
|
||||
if (partsOut.length === 1 && partsOut[0].type === "text") msgs.push({ role: "user", content: partsOut[0].text })
|
||||
else msgs.push({ role: "user", content: partsOut })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "assistant") {
|
||||
const partsIn = Array.isArray((m as any).content) ? (m as any).content : []
|
||||
const texts: string[] = []
|
||||
const tcs: any[] = []
|
||||
for (const p of partsIn) {
|
||||
if (!p || !(p as any).type) continue
|
||||
if ((p as any).type === "text" && typeof (p as any).text === "string") texts.push((p as any).text)
|
||||
if ((p as any).type === "tool_use") {
|
||||
const name = (p as any).name
|
||||
const id = (p as any).id
|
||||
const inp = (p as any).input
|
||||
const input = (() => {
|
||||
if (typeof inp === "string") return inp
|
||||
try {
|
||||
return JSON.stringify(inp ?? {})
|
||||
} catch {
|
||||
return String(inp ?? "")
|
||||
}
|
||||
})()
|
||||
tcs.push({ id, type: "function", function: { name, arguments: input } })
|
||||
}
|
||||
}
|
||||
const out: any = { role: "assistant", content: texts.join("") }
|
||||
if (tcs.length > 0) out.tool_calls = tcs
|
||||
msgs.push(out)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const tools = Array.isArray(body.tools)
|
||||
? body.tools
|
||||
.filter((t: any) => t && typeof t === "object" && "input_schema" in t)
|
||||
.map((t: any) => ({
|
||||
type: "function",
|
||||
function: { name: (t as any).name, description: (t as any).description, parameters: (t as any).input_schema },
|
||||
}))
|
||||
: undefined
|
||||
|
||||
const tcin = body.tool_choice
|
||||
const tc = (() => {
|
||||
if (!tcin) return undefined
|
||||
if ((tcin as any).type === "auto") return "auto"
|
||||
if ((tcin as any).type === "any") return "required"
|
||||
if ((tcin as any).type === "tool" && typeof (tcin as any).name === "string")
|
||||
return { type: "function" as const, function: { name: (tcin as any).name } }
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const stop = (() => {
|
||||
const v = body.stop_sequences
|
||||
if (!v) return undefined
|
||||
if (Array.isArray(v)) return v.length === 1 ? v[0] : v
|
||||
if (typeof v === "string") return v
|
||||
return undefined
|
||||
})()
|
||||
|
||||
return {
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
stop,
|
||||
messages: msgs,
|
||||
stream: !!body.stream,
|
||||
tools,
|
||||
tool_choice: tc,
|
||||
}
|
||||
}
|
||||
|
||||
export function toAnthropicRequest(body: CommonRequest) {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const sysIn = Array.isArray(body.messages) ? body.messages.filter((m: any) => m && m.role === "system") : []
|
||||
let ccCount = 0
|
||||
const cc = () => {
|
||||
ccCount++
|
||||
return ccCount <= 4 ? { cache_control: { type: "ephemeral" } } : {}
|
||||
}
|
||||
const system = sysIn
|
||||
.filter((m: any) => typeof m.content === "string" && m.content.length > 0)
|
||||
.map((m: any) => ({ type: "text", text: m.content, ...cc() }))
|
||||
|
||||
const msgsIn = Array.isArray(body.messages) ? body.messages : []
|
||||
const msgsOut: any[] = []
|
||||
|
||||
const toSrc = (p: any) => {
|
||||
if (!p || typeof p !== "object") return undefined
|
||||
if ((p as any).type === "image_url" && (p as any).image_url) {
|
||||
const u = (p as any).image_url.url ?? (p as any).image_url
|
||||
if (typeof u === "string" && u.startsWith("data:")) {
|
||||
const m = u.match(/^data:([^;]+);base64,(.*)$/)
|
||||
if (m) return { type: "base64", media_type: m[1], data: m[2] }
|
||||
}
|
||||
if (typeof u === "string") return { type: "url", url: u }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const m of msgsIn) {
|
||||
if (!m || !(m as any).role) continue
|
||||
|
||||
if ((m as any).role === "user") {
|
||||
if (typeof (m as any).content === "string") {
|
||||
msgsOut.push({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: (m as any).content, ...cc() }],
|
||||
})
|
||||
} else if (Array.isArray((m as any).content)) {
|
||||
const parts: any[] = []
|
||||
for (const p of (m as any).content) {
|
||||
if (!p || !(p as any).type) continue
|
||||
if ((p as any).type === "text" && typeof (p as any).text === "string")
|
||||
parts.push({ type: "text", text: (p as any).text, ...cc() })
|
||||
if ((p as any).type === "image_url") {
|
||||
const s = toSrc(p)
|
||||
if (s) parts.push({ type: "image", source: s, ...cc() })
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "assistant") {
|
||||
const out: any = { role: "assistant", content: [] as any[] }
|
||||
if (typeof (m as any).content === "string" && (m as any).content.length > 0) {
|
||||
;(out.content as any[]).push({ type: "text", text: (m as any).content, ...cc() })
|
||||
}
|
||||
if (Array.isArray((m as any).tool_calls)) {
|
||||
for (const tc of (m as any).tool_calls) {
|
||||
if ((tc as any).type === "function" && (tc as any).function) {
|
||||
let input: any
|
||||
const a = (tc as any).function.arguments
|
||||
if (typeof a === "string") {
|
||||
try {
|
||||
input = JSON.parse(a)
|
||||
} catch {
|
||||
input = a
|
||||
}
|
||||
} else input = a
|
||||
const id = (tc as any).id || `toolu_${Math.random().toString(36).slice(2)}`
|
||||
;(out.content as any[]).push({
|
||||
type: "tool_use",
|
||||
id,
|
||||
name: (tc as any).function.name,
|
||||
input,
|
||||
...cc(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((out.content as any[]).length > 0) msgsOut.push(out)
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "tool") {
|
||||
msgsOut.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: (m as any).tool_call_id,
|
||||
content: (m as any).content,
|
||||
...cc(),
|
||||
},
|
||||
],
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const tools = Array.isArray(body.tools)
|
||||
? body.tools
|
||||
.filter((t: any) => t && typeof t === "object" && (t as any).type === "function")
|
||||
.map((t: any) => ({
|
||||
name: (t as any).function.name,
|
||||
description: (t as any).function.description,
|
||||
input_schema: (t as any).function.parameters,
|
||||
...cc(),
|
||||
}))
|
||||
: undefined
|
||||
|
||||
const tcIn = body.tool_choice
|
||||
const tool_choice = (() => {
|
||||
if (!tcIn) return undefined
|
||||
if (tcIn === "auto") return { type: "auto" }
|
||||
if (tcIn === "required") return { type: "any" }
|
||||
if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
|
||||
return { type: "tool", name: (tcIn as any).function.name }
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const stop_sequences = (() => {
|
||||
const v = body.stop
|
||||
if (!v) return undefined
|
||||
if (Array.isArray(v)) return v
|
||||
if (typeof v === "string") return [v]
|
||||
return undefined
|
||||
})()
|
||||
|
||||
return {
|
||||
max_tokens: body.max_tokens ?? 32_000,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
system: system.length > 0 ? system : undefined,
|
||||
messages: msgsOut,
|
||||
stream: !!body.stream,
|
||||
tools,
|
||||
tool_choice,
|
||||
stop_sequences,
|
||||
}
|
||||
}
|
||||
|
||||
export function fromAnthropicResponse(resp: any): CommonResponse {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
|
||||
if (Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const isAnthropic = typeof (resp as any).type === "string" && (resp as any).type === "message"
|
||||
if (!isAnthropic) return resp
|
||||
|
||||
const idIn = (resp as any).id
|
||||
const id =
|
||||
typeof idIn === "string" ? idIn.replace(/^msg_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
|
||||
const model = (resp as any).model
|
||||
|
||||
const blocks: any[] = Array.isArray((resp as any).content) ? (resp as any).content : []
|
||||
const text = blocks
|
||||
.filter((b) => b && b.type === "text" && typeof (b as any).text === "string")
|
||||
.map((b: any) => b.text)
|
||||
.join("")
|
||||
const tcs = blocks
|
||||
.filter((b) => b && b.type === "tool_use")
|
||||
.map((b: any) => {
|
||||
const name = (b as any).name
|
||||
const args = (() => {
|
||||
const inp = (b as any).input
|
||||
if (typeof inp === "string") return inp
|
||||
try {
|
||||
return JSON.stringify(inp ?? {})
|
||||
} catch {
|
||||
return String(inp ?? "")
|
||||
}
|
||||
})()
|
||||
const tid =
|
||||
typeof (b as any).id === "string" && (b as any).id.length > 0
|
||||
? (b as any).id
|
||||
: `toolu_${Math.random().toString(36).slice(2)}`
|
||||
return { id: tid, type: "function" as const, function: { name, arguments: args } }
|
||||
})
|
||||
|
||||
const finish = (r: string | null) => {
|
||||
if (r === "end_turn") return "stop"
|
||||
if (r === "tool_use") return "tool_calls"
|
||||
if (r === "max_tokens") return "length"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
}
|
||||
|
||||
const u = (resp as any).usage
|
||||
const usage = (() => {
|
||||
if (!u) return undefined as any
|
||||
const pt = typeof (u as any).input_tokens === "number" ? (u as any).input_tokens : undefined
|
||||
const ct = typeof (u as any).output_tokens === "number" ? (u as any).output_tokens : undefined
|
||||
const total = pt != null && ct != null ? pt + ct : undefined
|
||||
const cached =
|
||||
typeof (u as any).cache_read_input_tokens === "number" ? (u as any).cache_read_input_tokens : undefined
|
||||
const details = cached != null ? { cached_tokens: cached } : undefined
|
||||
return {
|
||||
prompt_tokens: pt,
|
||||
completion_tokens: ct,
|
||||
total_tokens: total,
|
||||
...(details ? { prompt_tokens_details: details } : {}),
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
...(text && text.length > 0 ? { content: text } : {}),
|
||||
...(tcs.length > 0 ? { tool_calls: tcs } : {}),
|
||||
},
|
||||
finish_reason: finish((resp as any).stop_reason ?? null),
|
||||
},
|
||||
],
|
||||
...(usage ? { usage } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function toAnthropicResponse(resp: CommonResponse) {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
|
||||
if (!Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const choice = (resp as any).choices[0]
|
||||
if (!choice) return resp
|
||||
|
||||
const message = choice.message
|
||||
if (!message) return resp
|
||||
|
||||
const content: any[] = []
|
||||
|
||||
if (typeof message.content === "string" && message.content.length > 0)
|
||||
content.push({ type: "text", text: message.content })
|
||||
|
||||
if (Array.isArray(message.tool_calls)) {
|
||||
for (const tc of message.tool_calls) {
|
||||
if ((tc as any).type === "function" && (tc as any).function) {
|
||||
let input: any
|
||||
try {
|
||||
input = JSON.parse((tc as any).function.arguments)
|
||||
} catch {
|
||||
input = (tc as any).function.arguments
|
||||
}
|
||||
content.push({ type: "tool_use", id: (tc as any).id, name: (tc as any).function.name, input })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stop_reason = (() => {
|
||||
const r = choice.finish_reason
|
||||
if (r === "stop") return "end_turn"
|
||||
if (r === "tool_calls") return "tool_use"
|
||||
if (r === "length") return "max_tokens"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
|
||||
const usage = (() => {
|
||||
const u = (resp as any).usage
|
||||
if (!u) return undefined
|
||||
return {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens,
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id: (resp as any).id,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: content.length > 0 ? content : [{ type: "text", text: "" }],
|
||||
model: (resp as any).model,
|
||||
stop_reason,
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
export function fromAnthropicChunk(chunk: string): CommonChunk | string {
|
||||
// Anthropic sends two lines per part: "event: <type>\n" + "data: <json>"
|
||||
const lines = chunk.split("\n")
|
||||
const dataLine = lines.find((l) => l.startsWith("data: "))
|
||||
if (!dataLine) return chunk
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(dataLine.slice(6))
|
||||
} catch {
|
||||
return chunk
|
||||
}
|
||||
|
||||
const out: CommonChunk = {
|
||||
id: json.id ?? json.message?.id ?? "",
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: json.model ?? json.message?.model ?? "",
|
||||
choices: [],
|
||||
}
|
||||
|
||||
if (json.type === "content_block_start") {
|
||||
const cb = json.content_block
|
||||
if (cb?.type === "text") {
|
||||
out.choices.push({ index: json.index ?? 0, delta: { role: "assistant", content: "" }, finish_reason: null })
|
||||
} else if (cb?.type === "tool_use") {
|
||||
out.choices.push({
|
||||
index: json.index ?? 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: json.index ?? 0, id: cb.id, type: "function", function: { name: cb.name, arguments: "" } },
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (json.type === "content_block_delta") {
|
||||
const d = json.delta
|
||||
if (d?.type === "text_delta") {
|
||||
out.choices.push({ index: json.index ?? 0, delta: { content: d.text }, finish_reason: null })
|
||||
} else if (d?.type === "input_json_delta") {
|
||||
out.choices.push({
|
||||
index: json.index ?? 0,
|
||||
delta: { tool_calls: [{ index: json.index ?? 0, function: { arguments: d.partial_json } }] },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (json.type === "message_delta") {
|
||||
const d = json.delta
|
||||
const finish_reason = (() => {
|
||||
const r = d?.stop_reason
|
||||
if (r === "end_turn") return "stop"
|
||||
if (r === "tool_use") return "tool_calls"
|
||||
if (r === "max_tokens") return "length"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
|
||||
out.choices.push({ index: 0, delta: {}, finish_reason })
|
||||
}
|
||||
|
||||
if (json.usage) {
|
||||
const u = json.usage
|
||||
out.usage = {
|
||||
prompt_tokens: u.input_tokens,
|
||||
completion_tokens: u.output_tokens,
|
||||
total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0),
|
||||
...(u.cache_read_input_tokens ? { prompt_tokens_details: { cached_tokens: u.cache_read_input_tokens } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function toAnthropicChunk(chunk: CommonChunk): string {
|
||||
if (!chunk.choices || !Array.isArray(chunk.choices) || chunk.choices.length === 0) {
|
||||
return JSON.stringify({})
|
||||
}
|
||||
|
||||
const choice = chunk.choices[0]
|
||||
const delta = choice.delta
|
||||
if (!delta) return JSON.stringify({})
|
||||
|
||||
const result: any = {}
|
||||
|
||||
if (delta.content) {
|
||||
result.type = "content_block_delta"
|
||||
result.index = 0
|
||||
result.delta = { type: "text_delta", text: delta.content }
|
||||
}
|
||||
|
||||
if (delta.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
if (tc.function?.name) {
|
||||
result.type = "content_block_start"
|
||||
result.index = tc.index ?? 0
|
||||
result.content_block = { type: "tool_use", id: tc.id, name: tc.function.name, input: {} }
|
||||
} else if (tc.function?.arguments) {
|
||||
result.type = "content_block_delta"
|
||||
result.index = tc.index ?? 0
|
||||
result.delta = { type: "input_json_delta", partial_json: tc.function.arguments }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.finish_reason) {
|
||||
const stop_reason = (() => {
|
||||
const r = choice.finish_reason
|
||||
if (r === "stop") return "end_turn"
|
||||
if (r === "tool_calls") return "tool_use"
|
||||
if (r === "length") return "max_tokens"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
result.type = "message_delta"
|
||||
result.delta = { stop_reason, stop_sequence: null }
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
const u = chunk.usage
|
||||
result.usage = {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(result)
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
|
||||
|
||||
type Usage = {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
// used by moonshot
|
||||
cached_tokens?: number
|
||||
// used by xai
|
||||
prompt_tokens_details?: {
|
||||
text_tokens?: number
|
||||
audio_tokens?: number
|
||||
image_tokens?: number
|
||||
cached_tokens?: number
|
||||
}
|
||||
completion_tokens_details?: {
|
||||
reasoning_tokens?: number
|
||||
audio_tokens?: number
|
||||
accepted_prediction_tokens?: number
|
||||
rejected_prediction_tokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
export const oaCompatHelper = {
|
||||
format: "oa-compat",
|
||||
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
},
|
||||
modifyBody: (body: Record<string, any>) => {
|
||||
return {
|
||||
...body,
|
||||
...(body.stream ? { stream_options: { include_usage: true } } : {}),
|
||||
}
|
||||
},
|
||||
createUsageParser: () => {
|
||||
let usage: Usage
|
||||
|
||||
return {
|
||||
parse: (chunk: string) => {
|
||||
if (!chunk.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!json.usage) return
|
||||
usage = json.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.prompt_tokens ?? 0
|
||||
const outputTokens = usage.completion_tokens ?? 0
|
||||
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens: undefined,
|
||||
cacheWrite1hTokens: undefined,
|
||||
}
|
||||
},
|
||||
} satisfies ProviderHelper
|
||||
|
||||
export function fromOaCompatibleRequest(body: any): CommonRequest {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const msgsIn = Array.isArray(body.messages) ? body.messages : []
|
||||
const msgsOut: any[] = []
|
||||
|
||||
for (const m of msgsIn) {
|
||||
if (!m || !m.role) continue
|
||||
|
||||
if (m.role === "system") {
|
||||
if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content })
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "user") {
|
||||
if (typeof m.content === "string") {
|
||||
msgsOut.push({ role: "user", content: m.content })
|
||||
} else if (Array.isArray(m.content)) {
|
||||
const parts: any[] = []
|
||||
for (const p of m.content) {
|
||||
if (!p || !p.type) continue
|
||||
if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text })
|
||||
if (p.type === "image_url") parts.push({ type: "image_url", image_url: p.image_url })
|
||||
}
|
||||
if (parts.length === 1 && parts[0].type === "text") msgsOut.push({ role: "user", content: parts[0].text })
|
||||
else if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "assistant") {
|
||||
const out: any = { role: "assistant" }
|
||||
if (typeof m.content === "string") out.content = m.content
|
||||
if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls
|
||||
msgsOut.push(out)
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "tool") {
|
||||
msgsOut.push({ role: "tool", tool_call_id: m.tool_call_id, content: m.content })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
stop: body.stop,
|
||||
messages: msgsOut,
|
||||
stream: !!body.stream,
|
||||
tools: Array.isArray(body.tools) ? body.tools : undefined,
|
||||
tool_choice: body.tool_choice,
|
||||
}
|
||||
}
|
||||
|
||||
export function toOaCompatibleRequest(body: CommonRequest) {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const msgsIn = Array.isArray(body.messages) ? body.messages : []
|
||||
const msgsOut: any[] = []
|
||||
|
||||
const toImg = (p: any) => {
|
||||
if (!p || typeof p !== "object") return undefined
|
||||
if (p.type === "image_url" && p.image_url) return { type: "image_url", image_url: p.image_url }
|
||||
const s = (p as any).source
|
||||
if (!s || typeof s !== "object") return undefined
|
||||
if (s.type === "url" && typeof s.url === "string") return { type: "image_url", image_url: { url: s.url } }
|
||||
if (s.type === "base64" && typeof s.media_type === "string" && typeof s.data === "string")
|
||||
return { type: "image_url", image_url: { url: `data:${s.media_type};base64,${s.data}` } }
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const m of msgsIn) {
|
||||
if (!m || !m.role) continue
|
||||
|
||||
if (m.role === "system") {
|
||||
if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content })
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "user") {
|
||||
if (typeof m.content === "string") {
|
||||
msgsOut.push({ role: "user", content: m.content })
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(m.content)) {
|
||||
const parts: any[] = []
|
||||
for (const p of m.content) {
|
||||
if (!p || !p.type) continue
|
||||
if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text })
|
||||
const ip = toImg(p)
|
||||
if (ip) parts.push(ip)
|
||||
}
|
||||
if (parts.length === 1 && parts[0].type === "text") msgsOut.push({ role: "user", content: parts[0].text })
|
||||
else if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "assistant") {
|
||||
const out: any = { role: "assistant" }
|
||||
if (typeof m.content === "string") out.content = m.content
|
||||
if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls
|
||||
msgsOut.push(out)
|
||||
continue
|
||||
}
|
||||
|
||||
if (m.role === "tool") {
|
||||
msgsOut.push({ role: "tool", tool_call_id: m.tool_call_id, content: m.content })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const tools = Array.isArray(body.tools)
|
||||
? body.tools.map((tool: any) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
},
|
||||
}))
|
||||
: undefined
|
||||
|
||||
return {
|
||||
model: body.model,
|
||||
max_tokens: body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
stop: body.stop,
|
||||
messages: msgsOut,
|
||||
stream: !!body.stream,
|
||||
tools,
|
||||
tool_choice: body.tool_choice,
|
||||
response_format: (body as any).response_format,
|
||||
}
|
||||
}
|
||||
|
||||
export function fromOaCompatibleResponse(resp: any): CommonResponse {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
|
||||
if (!Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const choice = (resp as any).choices[0]
|
||||
if (!choice) return resp
|
||||
|
||||
const message = choice.message
|
||||
if (!message) return resp
|
||||
|
||||
const content: any[] = []
|
||||
|
||||
if (typeof message.content === "string" && message.content.length > 0) {
|
||||
content.push({ type: "text", text: message.content })
|
||||
}
|
||||
|
||||
if (Array.isArray(message.tool_calls)) {
|
||||
for (const toolCall of message.tool_calls) {
|
||||
if (toolCall.type === "function" && toolCall.function) {
|
||||
let input
|
||||
try {
|
||||
input = JSON.parse(toolCall.function.arguments)
|
||||
} catch {
|
||||
input = toolCall.function.arguments
|
||||
}
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
input,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stopReason = (() => {
|
||||
const reason = choice.finish_reason
|
||||
if (reason === "stop") return "stop"
|
||||
if (reason === "tool_calls") return "tool_calls"
|
||||
if (reason === "length") return "length"
|
||||
if (reason === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
|
||||
const usage = (() => {
|
||||
const u = (resp as any).usage
|
||||
if (!u) return undefined
|
||||
return {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
...(u.prompt_tokens_details?.cached_tokens
|
||||
? { prompt_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
|
||||
: {}),
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id: (resp as any).id,
|
||||
object: "chat.completion" as const,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: (resp as any).model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
...(content.length > 0 && content.some((c) => c.type === "text")
|
||||
? {
|
||||
content: content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join(""),
|
||||
}
|
||||
: {}),
|
||||
...(content.length > 0 && content.some((c) => c.type === "tool_use")
|
||||
? {
|
||||
tool_calls: content
|
||||
.filter((c) => c.type === "tool_use")
|
||||
.map((c: any) => ({
|
||||
id: c.id,
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: c.name,
|
||||
arguments: typeof c.input === "string" ? c.input : JSON.stringify(c.input),
|
||||
},
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
finish_reason: stopReason,
|
||||
},
|
||||
],
|
||||
...(usage ? { usage } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function toOaCompatibleResponse(resp: CommonResponse) {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
|
||||
if (Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const isAnthropic = typeof (resp as any).type === "string" && (resp as any).type === "message"
|
||||
if (!isAnthropic) return resp
|
||||
|
||||
const idIn = (resp as any).id
|
||||
const id =
|
||||
typeof idIn === "string" ? idIn.replace(/^msg_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
|
||||
const model = (resp as any).model
|
||||
|
||||
const blocks: any[] = Array.isArray((resp as any).content) ? (resp as any).content : []
|
||||
const text = blocks
|
||||
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
||||
.map((b) => b.text)
|
||||
.join("")
|
||||
const tcs = blocks
|
||||
.filter((b) => b && b.type === "tool_use")
|
||||
.map((b) => {
|
||||
const name = (b as any).name
|
||||
const args = (() => {
|
||||
const inp = (b as any).input
|
||||
if (typeof inp === "string") return inp
|
||||
try {
|
||||
return JSON.stringify(inp ?? {})
|
||||
} catch {
|
||||
return String(inp ?? "")
|
||||
}
|
||||
})()
|
||||
const tid =
|
||||
typeof (b as any).id === "string" && (b as any).id.length > 0
|
||||
? (b as any).id
|
||||
: `toolu_${Math.random().toString(36).slice(2)}`
|
||||
return { id: tid, type: "function" as const, function: { name, arguments: args } }
|
||||
})
|
||||
|
||||
const finish = (r: string | null) => {
|
||||
if (r === "end_turn") return "stop"
|
||||
if (r === "tool_use") return "tool_calls"
|
||||
if (r === "max_tokens") return "length"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
}
|
||||
|
||||
const u = (resp as any).usage
|
||||
const usage = (() => {
|
||||
if (!u) return undefined as any
|
||||
const pt = typeof u.input_tokens === "number" ? u.input_tokens : undefined
|
||||
const ct = typeof u.output_tokens === "number" ? u.output_tokens : undefined
|
||||
const total = pt != null && ct != null ? pt + ct : undefined
|
||||
const cached = typeof u.cache_read_input_tokens === "number" ? u.cache_read_input_tokens : undefined
|
||||
const details = cached != null ? { cached_tokens: cached } : undefined
|
||||
return {
|
||||
prompt_tokens: pt,
|
||||
completion_tokens: ct,
|
||||
total_tokens: total,
|
||||
...(details ? { prompt_tokens_details: details } : {}),
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
...(text && text.length > 0 ? { content: text } : {}),
|
||||
...(tcs.length > 0 ? { tool_calls: tcs } : {}),
|
||||
},
|
||||
finish_reason: finish((resp as any).stop_reason ?? null),
|
||||
},
|
||||
],
|
||||
...(usage ? { usage } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function fromOaCompatibleChunk(chunk: string): CommonChunk | string {
|
||||
if (!chunk.startsWith("data: ")) return chunk
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6))
|
||||
} catch {
|
||||
return chunk
|
||||
}
|
||||
|
||||
if (!json.choices || !Array.isArray(json.choices) || json.choices.length === 0) {
|
||||
return chunk
|
||||
}
|
||||
|
||||
const choice = json.choices[0]
|
||||
const delta = choice.delta
|
||||
|
||||
if (!delta) return chunk
|
||||
|
||||
const result: CommonChunk = {
|
||||
id: json.id ?? "",
|
||||
object: "chat.completion.chunk",
|
||||
created: json.created ?? Math.floor(Date.now() / 1000),
|
||||
model: json.model ?? "",
|
||||
choices: [],
|
||||
}
|
||||
|
||||
if (delta.content) {
|
||||
result.choices.push({
|
||||
index: choice.index ?? 0,
|
||||
delta: { content: delta.content },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
|
||||
if (delta.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
result.choices.push({
|
||||
index: choice.index ?? 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: toolCall.index ?? 0,
|
||||
id: toolCall.id,
|
||||
type: toolCall.type ?? "function",
|
||||
function: toolCall.function,
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.finish_reason) {
|
||||
result.choices.push({
|
||||
index: choice.index ?? 0,
|
||||
delta: {},
|
||||
finish_reason: choice.finish_reason,
|
||||
})
|
||||
}
|
||||
|
||||
if (json.usage) {
|
||||
const usage = json.usage
|
||||
result.usage = {
|
||||
prompt_tokens: usage.prompt_tokens,
|
||||
completion_tokens: usage.completion_tokens,
|
||||
total_tokens: usage.total_tokens,
|
||||
...(usage.prompt_tokens_details?.cached_tokens
|
||||
? { prompt_tokens_details: { cached_tokens: usage.prompt_tokens_details.cached_tokens } }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function toOaCompatibleChunk(chunk: CommonChunk): string {
|
||||
const result: any = {
|
||||
id: chunk.id,
|
||||
object: "chat.completion.chunk",
|
||||
created: chunk.created,
|
||||
model: chunk.model,
|
||||
choices: [],
|
||||
}
|
||||
|
||||
if (!chunk.choices || chunk.choices.length === 0) {
|
||||
return `data: ${JSON.stringify(result)}`
|
||||
}
|
||||
|
||||
const choice = chunk.choices[0]
|
||||
const delta = choice.delta
|
||||
|
||||
if (delta?.role) {
|
||||
result.choices.push({
|
||||
index: choice.index,
|
||||
delta: { role: delta.role },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
|
||||
if (delta?.content) {
|
||||
result.choices.push({
|
||||
index: choice.index,
|
||||
delta: { content: delta.content },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
result.choices.push({
|
||||
index: choice.index,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: tc.index,
|
||||
id: tc.id,
|
||||
type: tc.type,
|
||||
function: tc.function,
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.finish_reason) {
|
||||
result.choices.push({
|
||||
index: choice.index,
|
||||
delta: {},
|
||||
finish_reason: choice.finish_reason,
|
||||
})
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
result.usage = {
|
||||
prompt_tokens: chunk.usage.prompt_tokens,
|
||||
completion_tokens: chunk.usage.completion_tokens,
|
||||
total_tokens: chunk.usage.total_tokens,
|
||||
...(chunk.usage.prompt_tokens_details?.cached_tokens
|
||||
? {
|
||||
prompt_tokens_details: { cached_tokens: chunk.usage.prompt_tokens_details.cached_tokens },
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
return `data: ${JSON.stringify(result)}`
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
|
||||
|
||||
type Usage = {
|
||||
input_tokens?: number
|
||||
input_tokens_details?: {
|
||||
cached_tokens?: number
|
||||
}
|
||||
output_tokens?: number
|
||||
output_tokens_details?: {
|
||||
reasoning_tokens?: number
|
||||
}
|
||||
total_tokens?: number
|
||||
}
|
||||
|
||||
export const openaiHelper = {
|
||||
format: "openai",
|
||||
modifyUrl: (providerApi: string) => providerApi + "/responses",
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
},
|
||||
modifyBody: (body: Record<string, any>) => {
|
||||
return body
|
||||
},
|
||||
createUsageParser: () => {
|
||||
let usage: Usage
|
||||
|
||||
return {
|
||||
parse: (chunk: string) => {
|
||||
const [event, data] = chunk.split("\n")
|
||||
if (event !== "event: response.completed") return
|
||||
if (!data.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!json.response?.usage) return
|
||||
usage = json.response.usage
|
||||
},
|
||||
retrieve: () => usage,
|
||||
}
|
||||
},
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.input_tokens ?? 0
|
||||
const outputTokens = usage.output_tokens ?? 0
|
||||
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
|
||||
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens: outputTokens - (reasoningTokens ?? 0),
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens: undefined,
|
||||
cacheWrite1hTokens: undefined,
|
||||
}
|
||||
},
|
||||
} satisfies ProviderHelper
|
||||
|
||||
export function fromOpenaiRequest(body: any): CommonRequest {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const toImg = (p: any) => {
|
||||
if (!p || typeof p !== "object") return undefined
|
||||
if ((p as any).type === "image_url" && (p as any).image_url)
|
||||
return { type: "image_url", image_url: (p as any).image_url }
|
||||
if ((p as any).type === "input_image" && (p as any).image_url)
|
||||
return { type: "image_url", image_url: (p as any).image_url }
|
||||
const s = (p as any).source
|
||||
if (!s || typeof s !== "object") return undefined
|
||||
if ((s as any).type === "url" && typeof (s as any).url === "string")
|
||||
return { type: "image_url", image_url: { url: (s as any).url } }
|
||||
if (
|
||||
(s as any).type === "base64" &&
|
||||
typeof (s as any).media_type === "string" &&
|
||||
typeof (s as any).data === "string"
|
||||
)
|
||||
return { type: "image_url", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } }
|
||||
return undefined
|
||||
}
|
||||
|
||||
const msgs: any[] = []
|
||||
|
||||
const inMsgs = Array.isArray(body.input) ? body.input : Array.isArray(body.messages) ? body.messages : []
|
||||
|
||||
for (const m of inMsgs) {
|
||||
if (!m) continue
|
||||
|
||||
// Responses API items without role:
|
||||
if (!(m as any).role && (m as any).type) {
|
||||
if ((m as any).type === "function_call") {
|
||||
const name = (m as any).name
|
||||
const a = (m as any).arguments
|
||||
const args = typeof a === "string" ? a : JSON.stringify(a ?? {})
|
||||
msgs.push({
|
||||
role: "assistant",
|
||||
tool_calls: [{ id: (m as any).id, type: "function", function: { name, arguments: args } }],
|
||||
})
|
||||
}
|
||||
if ((m as any).type === "function_call_output") {
|
||||
const id = (m as any).call_id
|
||||
const out = (m as any).output
|
||||
const content = typeof out === "string" ? out : JSON.stringify(out)
|
||||
msgs.push({ role: "tool", tool_call_id: id, content })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "system" || (m as any).role === "developer") {
|
||||
const c = (m as any).content
|
||||
if (typeof c === "string" && c.length > 0) msgs.push({ role: "system", content: c })
|
||||
if (Array.isArray(c)) {
|
||||
const t = c.find((p: any) => p && typeof p.text === "string")
|
||||
if (t && typeof t.text === "string" && t.text.length > 0) msgs.push({ role: "system", content: t.text })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "user") {
|
||||
const c = (m as any).content
|
||||
if (typeof c === "string") {
|
||||
msgs.push({ role: "user", content: c })
|
||||
} else if (Array.isArray(c)) {
|
||||
const parts: any[] = []
|
||||
for (const p of c) {
|
||||
if (!p || !(p as any).type) continue
|
||||
if (((p as any).type === "text" || (p as any).type === "input_text") && typeof (p as any).text === "string")
|
||||
parts.push({ type: "text", text: (p as any).text })
|
||||
const ip = toImg(p)
|
||||
if (ip) parts.push(ip)
|
||||
if ((p as any).type === "tool_result") {
|
||||
const id = (p as any).tool_call_id
|
||||
const content =
|
||||
typeof (p as any).content === "string" ? (p as any).content : JSON.stringify((p as any).content)
|
||||
msgs.push({ role: "tool", tool_call_id: id, content })
|
||||
}
|
||||
}
|
||||
if (parts.length === 1 && parts[0].type === "text") msgs.push({ role: "user", content: parts[0].text })
|
||||
else if (parts.length > 0) msgs.push({ role: "user", content: parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "assistant") {
|
||||
const c = (m as any).content
|
||||
const out: any = { role: "assistant" }
|
||||
if (typeof c === "string" && c.length > 0) out.content = c
|
||||
if (Array.isArray((m as any).tool_calls)) out.tool_calls = (m as any).tool_calls
|
||||
msgs.push(out)
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "tool") {
|
||||
msgs.push({ role: "tool", tool_call_id: (m as any).tool_call_id, content: (m as any).content })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const tcIn = body.tool_choice
|
||||
const tc = (() => {
|
||||
if (!tcIn) return undefined
|
||||
if (tcIn === "auto") return "auto"
|
||||
if (tcIn === "required") return "required"
|
||||
if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
|
||||
return { type: "function" as const, function: { name: (tcIn as any).function.name } }
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const stop = (() => {
|
||||
const v = body.stop_sequences ?? body.stop
|
||||
if (!v) return undefined
|
||||
if (Array.isArray(v)) return v.length === 1 ? v[0] : v
|
||||
if (typeof v === "string") return v
|
||||
return undefined
|
||||
})()
|
||||
|
||||
return {
|
||||
max_tokens: body.max_output_tokens ?? body.max_tokens,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
stop,
|
||||
messages: msgs,
|
||||
stream: !!body.stream,
|
||||
tools: Array.isArray(body.tools) ? body.tools : undefined,
|
||||
tool_choice: tc,
|
||||
}
|
||||
}
|
||||
|
||||
export function toOpenaiRequest(body: CommonRequest) {
|
||||
if (!body || typeof body !== "object") return body
|
||||
|
||||
const msgsIn = Array.isArray(body.messages) ? body.messages : []
|
||||
const input: any[] = []
|
||||
|
||||
const toPart = (p: any) => {
|
||||
if (!p || typeof p !== "object") return undefined
|
||||
if ((p as any).type === "text" && typeof (p as any).text === "string")
|
||||
return { type: "input_text", text: (p as any).text }
|
||||
if ((p as any).type === "image_url" && (p as any).image_url)
|
||||
return { type: "input_image", image_url: (p as any).image_url }
|
||||
const s = (p as any).source
|
||||
if (!s || typeof s !== "object") return undefined
|
||||
if ((s as any).type === "url" && typeof (s as any).url === "string")
|
||||
return { type: "input_image", image_url: { url: (s as any).url } }
|
||||
if (
|
||||
(s as any).type === "base64" &&
|
||||
typeof (s as any).media_type === "string" &&
|
||||
typeof (s as any).data === "string"
|
||||
)
|
||||
return { type: "input_image", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } }
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const m of msgsIn) {
|
||||
if (!m || !(m as any).role) continue
|
||||
|
||||
if ((m as any).role === "system") {
|
||||
const c = (m as any).content
|
||||
if (typeof c === "string") input.push({ role: "system", content: c })
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "user") {
|
||||
const c = (m as any).content
|
||||
if (typeof c === "string") {
|
||||
input.push({ role: "user", content: [{ type: "input_text", text: c }] })
|
||||
} else if (Array.isArray(c)) {
|
||||
const parts: any[] = []
|
||||
for (const p of c) {
|
||||
const op = toPart(p)
|
||||
if (op) parts.push(op)
|
||||
}
|
||||
if (parts.length > 0) input.push({ role: "user", content: parts })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "assistant") {
|
||||
const c = (m as any).content
|
||||
if (typeof c === "string" && c.length > 0) {
|
||||
input.push({ role: "assistant", content: [{ type: "output_text", text: c }] })
|
||||
}
|
||||
if (Array.isArray((m as any).tool_calls)) {
|
||||
for (const tc of (m as any).tool_calls) {
|
||||
if ((tc as any).type === "function" && (tc as any).function) {
|
||||
const name = (tc as any).function.name
|
||||
const a = (tc as any).function.arguments
|
||||
const args = typeof a === "string" ? a : JSON.stringify(a)
|
||||
input.push({ type: "function_call", call_id: (tc as any).id, name, arguments: args })
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((m as any).role === "tool") {
|
||||
const out = typeof (m as any).content === "string" ? (m as any).content : JSON.stringify((m as any).content)
|
||||
input.push({ type: "function_call_output", call_id: (m as any).tool_call_id, output: out })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const stop_sequences = (() => {
|
||||
const v = body.stop
|
||||
if (!v) return undefined
|
||||
if (Array.isArray(v)) return v
|
||||
if (typeof v === "string") return [v]
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const tcIn = body.tool_choice
|
||||
const tool_choice = (() => {
|
||||
if (!tcIn) return undefined
|
||||
if (tcIn === "auto") return "auto"
|
||||
if (tcIn === "required") return "required"
|
||||
if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
|
||||
return { type: "function", function: { name: (tcIn as any).function.name } }
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const tools = (() => {
|
||||
if (!Array.isArray(body.tools)) return undefined
|
||||
return body.tools.map((tool: any) => {
|
||||
if (tool.type === "function") {
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.function?.name,
|
||||
description: tool.function?.description,
|
||||
parameters: tool.function?.parameters,
|
||||
strict: tool.function?.strict,
|
||||
}
|
||||
}
|
||||
return tool
|
||||
})
|
||||
})()
|
||||
|
||||
return {
|
||||
model: body.model,
|
||||
input,
|
||||
max_output_tokens: body.max_tokens,
|
||||
top_p: body.top_p,
|
||||
stop_sequences,
|
||||
stream: !!body.stream,
|
||||
tools,
|
||||
tool_choice,
|
||||
include: Array.isArray((body as any).include) ? (body as any).include : undefined,
|
||||
truncation: (body as any).truncation,
|
||||
metadata: (body as any).metadata,
|
||||
store: (body as any).store,
|
||||
user: (body as any).user,
|
||||
text: { verbosity: "low" },
|
||||
reasoning: { effort: "medium" },
|
||||
}
|
||||
}
|
||||
|
||||
export function fromOpenaiResponse(resp: any): CommonResponse {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
if (Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const r = (resp as any).response ?? resp
|
||||
if (!r || typeof r !== "object") return resp
|
||||
|
||||
const idIn = (r as any).id
|
||||
const id =
|
||||
typeof idIn === "string" ? idIn.replace(/^resp_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
|
||||
const model = (r as any).model ?? (resp as any).model
|
||||
|
||||
const out = Array.isArray((r as any).output) ? (r as any).output : []
|
||||
const text = out
|
||||
.filter((o: any) => o && o.type === "message" && Array.isArray((o as any).content))
|
||||
.flatMap((o: any) => (o as any).content)
|
||||
.filter((p: any) => p && p.type === "output_text" && typeof p.text === "string")
|
||||
.map((p: any) => p.text)
|
||||
.join("")
|
||||
|
||||
const tcs = out
|
||||
.filter((o: any) => o && o.type === "function_call")
|
||||
.map((o: any) => {
|
||||
const name = (o as any).name
|
||||
const a = (o as any).arguments
|
||||
const args = typeof a === "string" ? a : JSON.stringify(a ?? {})
|
||||
const tid =
|
||||
typeof (o as any).id === "string" && (o as any).id.length > 0
|
||||
? (o as any).id
|
||||
: `toolu_${Math.random().toString(36).slice(2)}`
|
||||
return { id: tid, type: "function" as const, function: { name, arguments: args } }
|
||||
})
|
||||
|
||||
const finish = (r: string | null) => {
|
||||
if (r === "stop") return "stop"
|
||||
if (r === "tool_call" || r === "tool_calls") return "tool_calls"
|
||||
if (r === "length" || r === "max_output_tokens") return "length"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
}
|
||||
|
||||
const u = (r as any).usage ?? (resp as any).usage
|
||||
const usage = (() => {
|
||||
if (!u) return undefined as any
|
||||
const pt = typeof (u as any).input_tokens === "number" ? (u as any).input_tokens : undefined
|
||||
const ct = typeof (u as any).output_tokens === "number" ? (u as any).output_tokens : undefined
|
||||
const total = pt != null && ct != null ? pt + ct : undefined
|
||||
const cached = (u as any).input_tokens_details?.cached_tokens
|
||||
const details = typeof cached === "number" ? { cached_tokens: cached } : undefined
|
||||
return {
|
||||
prompt_tokens: pt,
|
||||
completion_tokens: ct,
|
||||
total_tokens: total,
|
||||
...(details ? { prompt_tokens_details: details } : {}),
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
...(text && text.length > 0 ? { content: text } : {}),
|
||||
...(tcs.length > 0 ? { tool_calls: tcs } : {}),
|
||||
},
|
||||
finish_reason: finish((r as any).stop_reason ?? null),
|
||||
},
|
||||
],
|
||||
...(usage ? { usage } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function toOpenaiResponse(resp: CommonResponse) {
|
||||
if (!resp || typeof resp !== "object") return resp
|
||||
if (!Array.isArray((resp as any).choices)) return resp
|
||||
|
||||
const choice = (resp as any).choices[0]
|
||||
if (!choice) return resp
|
||||
|
||||
const msg = choice.message
|
||||
if (!msg) return resp
|
||||
|
||||
const outputItems: any[] = []
|
||||
|
||||
if (typeof msg.content === "string" && msg.content.length > 0) {
|
||||
outputItems.push({
|
||||
id: `msg_${Math.random().toString(36).slice(2)}`,
|
||||
type: "message",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: msg.content, annotations: [], logprobs: [] }],
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(msg.tool_calls)) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
if ((tc as any).type === "function" && (tc as any).function) {
|
||||
outputItems.push({
|
||||
id: (tc as any).id,
|
||||
type: "function_call",
|
||||
name: (tc as any).function.name,
|
||||
call_id: (tc as any).id,
|
||||
arguments: (tc as any).function.arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stop_reason = (() => {
|
||||
const r = choice.finish_reason
|
||||
if (r === "stop") return "stop"
|
||||
if (r === "tool_calls") return "tool_call"
|
||||
if (r === "length") return "max_output_tokens"
|
||||
if (r === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
|
||||
const usage = (() => {
|
||||
const u = (resp as any).usage
|
||||
if (!u) return undefined
|
||||
return {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
...(u.prompt_tokens_details?.cached_tokens
|
||||
? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
|
||||
: {}),
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
id: (resp as any).id?.replace(/^chatcmpl_/, "resp_") ?? `resp_${Math.random().toString(36).slice(2)}`,
|
||||
object: "response",
|
||||
model: (resp as any).model,
|
||||
output: outputItems,
|
||||
stop_reason,
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
export function fromOpenaiChunk(chunk: string): CommonChunk | string {
|
||||
const lines = chunk.split("\n")
|
||||
const ev = lines[0]
|
||||
const dl = lines[1]
|
||||
if (!ev || !dl || !dl.startsWith("data: ")) return chunk
|
||||
|
||||
let json: any
|
||||
try {
|
||||
json = JSON.parse(dl.slice(6))
|
||||
} catch {
|
||||
return chunk
|
||||
}
|
||||
|
||||
const respObj = json.response ?? {}
|
||||
|
||||
const out: CommonChunk = {
|
||||
id: respObj.id ?? json.id ?? "",
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: respObj.model ?? json.model ?? "",
|
||||
choices: [],
|
||||
}
|
||||
|
||||
const e = ev.replace("event: ", "").trim()
|
||||
|
||||
if (e === "response.output_text.delta") {
|
||||
const d = (json as any).delta ?? (json as any).text ?? (json as any).output_text_delta
|
||||
if (typeof d === "string" && d.length > 0)
|
||||
out.choices.push({ index: 0, delta: { content: d }, finish_reason: null })
|
||||
}
|
||||
|
||||
if (e === "response.output_item.added" && (json as any).item?.type === "function_call") {
|
||||
const name = (json as any).item?.name
|
||||
const id = (json as any).item?.id
|
||||
if (typeof name === "string" && name.length > 0) {
|
||||
out.choices.push({
|
||||
index: 0,
|
||||
delta: { tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }] },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (e === "response.function_call_arguments.delta") {
|
||||
const a = (json as any).delta ?? (json as any).arguments_delta
|
||||
if (typeof a === "string" && a.length > 0) {
|
||||
out.choices.push({
|
||||
index: 0,
|
||||
delta: { tool_calls: [{ index: 0, function: { arguments: a } }] },
|
||||
finish_reason: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (e === "response.completed") {
|
||||
const fr = (() => {
|
||||
const sr = (respObj as any).stop_reason ?? (json as any).stop_reason
|
||||
if (sr === "stop") return "stop"
|
||||
if (sr === "tool_call" || sr === "tool_calls") return "tool_calls"
|
||||
if (sr === "length" || sr === "max_output_tokens") return "length"
|
||||
if (sr === "content_filter") return "content_filter"
|
||||
return null
|
||||
})()
|
||||
out.choices.push({ index: 0, delta: {}, finish_reason: fr })
|
||||
|
||||
const u = (respObj as any).usage ?? (json as any).response?.usage
|
||||
if (u) {
|
||||
out.usage = {
|
||||
prompt_tokens: u.input_tokens,
|
||||
completion_tokens: u.output_tokens,
|
||||
total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0),
|
||||
...(u.input_tokens_details?.cached_tokens
|
||||
? { prompt_tokens_details: { cached_tokens: u.input_tokens_details.cached_tokens } }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function toOpenaiChunk(chunk: CommonChunk): string {
|
||||
if (!chunk.choices || !Array.isArray(chunk.choices) || chunk.choices.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const choice = chunk.choices[0]
|
||||
const d = choice.delta
|
||||
if (!d) return ""
|
||||
|
||||
const id = chunk.id
|
||||
const model = chunk.model
|
||||
|
||||
if (d.content) {
|
||||
const data = { id, type: "response.output_text.delta", delta: d.content, response: { id, model } }
|
||||
return `event: response.output_text.delta\ndata: ${JSON.stringify(data)}`
|
||||
}
|
||||
|
||||
if (d.tool_calls) {
|
||||
for (const tc of d.tool_calls) {
|
||||
if (tc.function?.name) {
|
||||
const data = {
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { id: tc.id, type: "function_call", name: tc.function.name, call_id: tc.id, arguments: "" },
|
||||
}
|
||||
return `event: response.output_item.added\ndata: ${JSON.stringify(data)}`
|
||||
}
|
||||
if (tc.function?.arguments) {
|
||||
const data = {
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 0,
|
||||
delta: tc.function.arguments,
|
||||
}
|
||||
return `event: response.function_call_arguments.delta\ndata: ${JSON.stringify(data)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (choice.finish_reason) {
|
||||
const u = chunk.usage
|
||||
const usage = u
|
||||
? {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
...(u.prompt_tokens_details?.cached_tokens
|
||||
? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
|
||||
: {}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
const data: any = { id, type: "response.completed", response: { id, model, ...(usage ? { usage } : {}) } }
|
||||
return `event: response.completed\ndata: ${JSON.stringify(data)}`
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Format } from "../format"
|
||||
|
||||
import {
|
||||
fromAnthropicChunk,
|
||||
fromAnthropicRequest,
|
||||
fromAnthropicResponse,
|
||||
toAnthropicChunk,
|
||||
toAnthropicRequest,
|
||||
toAnthropicResponse,
|
||||
} from "./anthropic"
|
||||
import {
|
||||
fromOpenaiChunk,
|
||||
fromOpenaiRequest,
|
||||
fromOpenaiResponse,
|
||||
toOpenaiChunk,
|
||||
toOpenaiRequest,
|
||||
toOpenaiResponse,
|
||||
} from "./openai"
|
||||
import {
|
||||
fromOaCompatibleChunk,
|
||||
fromOaCompatibleRequest,
|
||||
fromOaCompatibleResponse,
|
||||
toOaCompatibleChunk,
|
||||
toOaCompatibleRequest,
|
||||
toOaCompatibleResponse,
|
||||
} from "./openai-compatible"
|
||||
|
||||
export type ProviderHelper = {
|
||||
format: Format
|
||||
modifyUrl: (providerApi: string) => string
|
||||
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
|
||||
modifyBody: (body: Record<string, any>) => Record<string, any>
|
||||
createUsageParser: () => {
|
||||
parse: (chunk: string) => void
|
||||
retrieve: () => any
|
||||
}
|
||||
normalizeUsage: (usage: any) => {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWrite5mTokens?: number
|
||||
cacheWrite1hTokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonMessage {
|
||||
role: "system" | "user" | "assistant" | "tool"
|
||||
content?: string | Array<CommonContentPart>
|
||||
tool_call_id?: string
|
||||
tool_calls?: CommonToolCall[]
|
||||
}
|
||||
|
||||
export interface CommonContentPart {
|
||||
type: "text" | "image_url"
|
||||
text?: string
|
||||
image_url?: { url: string }
|
||||
}
|
||||
|
||||
export interface CommonToolCall {
|
||||
id: string
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonTool {
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonUsage {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
input_tokens_details?: {
|
||||
cached_tokens?: number
|
||||
}
|
||||
output_tokens_details?: {
|
||||
reasoning_tokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonRequest {
|
||||
model?: string
|
||||
max_tokens?: number
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
stop?: string | string[]
|
||||
messages: CommonMessage[]
|
||||
stream?: boolean
|
||||
tools?: CommonTool[]
|
||||
tool_choice?: "auto" | "required" | { type: "function"; function: { name: string } }
|
||||
}
|
||||
|
||||
export interface CommonResponse {
|
||||
id: string
|
||||
object: "chat.completion"
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
message: {
|
||||
role: "assistant"
|
||||
content?: string
|
||||
tool_calls?: CommonToolCall[]
|
||||
}
|
||||
finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null
|
||||
}>
|
||||
usage?: {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
prompt_tokens_details?: { cached_tokens?: number }
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommonChunk {
|
||||
id: string
|
||||
object: "chat.completion.chunk"
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
delta: {
|
||||
role?: "assistant"
|
||||
content?: string
|
||||
tool_calls?: Array<{
|
||||
index: number
|
||||
id?: string
|
||||
type?: "function"
|
||||
function?: {
|
||||
name?: string
|
||||
arguments?: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null
|
||||
}>
|
||||
usage?: {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
prompt_tokens_details?: { cached_tokens?: number }
|
||||
}
|
||||
}
|
||||
|
||||
export function createBodyConverter(from: Format, to: Format) {
|
||||
return (body: any): any => {
|
||||
if (from === to) return body
|
||||
|
||||
let raw: CommonRequest
|
||||
if (from === "anthropic") raw = fromAnthropicRequest(body)
|
||||
else if (from === "openai") raw = fromOpenaiRequest(body)
|
||||
else raw = fromOaCompatibleRequest(body)
|
||||
|
||||
if (to === "anthropic") return toAnthropicRequest(raw)
|
||||
if (to === "openai") return toOpenaiRequest(raw)
|
||||
if (to === "oa-compat") return toOaCompatibleRequest(raw)
|
||||
}
|
||||
}
|
||||
|
||||
export function createStreamPartConverter(from: Format, to: Format) {
|
||||
return (part: any): any => {
|
||||
if (from === to) return part
|
||||
|
||||
let raw: CommonChunk | string
|
||||
if (from === "anthropic") raw = fromAnthropicChunk(part)
|
||||
else if (from === "openai") raw = fromOpenaiChunk(part)
|
||||
else raw = fromOaCompatibleChunk(part)
|
||||
|
||||
// If result is a string (error case), pass it through
|
||||
if (typeof raw === "string") return raw
|
||||
|
||||
if (to === "anthropic") return toAnthropicChunk(raw)
|
||||
if (to === "openai") return toOpenaiChunk(raw)
|
||||
if (to === "oa-compat") return toOaCompatibleChunk(raw)
|
||||
}
|
||||
}
|
||||
|
||||
export function createResponseConverter(from: Format, to: Format) {
|
||||
return (response: any): any => {
|
||||
if (from === to) return response
|
||||
|
||||
let raw: CommonResponse
|
||||
if (from === "anthropic") raw = fromAnthropicResponse(response)
|
||||
else if (from === "openai") raw = fromOpenaiResponse(response)
|
||||
else raw = fromOaCompatibleResponse(response)
|
||||
|
||||
if (to === "anthropic") return toAnthropicResponse(raw)
|
||||
if (to === "openai") return toOpenaiResponse(raw)
|
||||
if (to === "oa-compat") return toOaCompatibleResponse(raw)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,9 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/handler"
|
||||
|
||||
type Usage = {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
// used by moonshot
|
||||
cached_tokens?: number
|
||||
// used by xai
|
||||
prompt_tokens_details?: {
|
||||
text_tokens?: number
|
||||
audio_tokens?: number
|
||||
image_tokens?: number
|
||||
cached_tokens?: number
|
||||
}
|
||||
completion_tokens_details?: {
|
||||
reasoning_tokens?: number
|
||||
audio_tokens?: number
|
||||
accepted_prediction_tokens?: number
|
||||
rejected_prediction_tokens?: number
|
||||
}
|
||||
}
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
let usage: Usage
|
||||
return handler(input, {
|
||||
modifyBody: (body: any) => ({
|
||||
...body,
|
||||
...(body.stream ? { stream_options: { include_usage: true } } : {}),
|
||||
}),
|
||||
setAuthHeader: (headers: Headers, apiKey: string) => {
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
},
|
||||
format: "oa-compat",
|
||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||
onStreamPart: (chunk: string) => {
|
||||
if (!chunk.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!json.usage) return
|
||||
usage = json.usage
|
||||
},
|
||||
getStreamUsage: () => usage,
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.prompt_tokens ?? 0
|
||||
const outputTokens = usage.completion_tokens ?? 0
|
||||
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens: outputTokens - (reasoningTokens ?? 0),
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,64 +1,9 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/handler"
|
||||
|
||||
type Usage = {
|
||||
cache_creation?: {
|
||||
ephemeral_5m_input_tokens?: number
|
||||
ephemeral_1h_input_tokens?: number
|
||||
}
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
server_tool_use?: {
|
||||
web_search_requests?: number
|
||||
}
|
||||
}
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
let usage: Usage
|
||||
return handler(input, {
|
||||
modifyBody: (body: any) => ({
|
||||
...body,
|
||||
service_tier: "standard_only",
|
||||
}),
|
||||
setAuthHeader: (headers: Headers, apiKey: string) => headers.set("x-api-key", apiKey),
|
||||
format: "anthropic",
|
||||
parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
|
||||
onStreamPart: (chunk: string) => {
|
||||
const data = chunk.split("\n")[1]
|
||||
if (!data.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6))
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
// ie. { type: "message_start"; message: { usage: Usage } }
|
||||
// ie. { type: "message_delta"; usage: Usage }
|
||||
const usageUpdate = json.usage ?? json.message?.usage
|
||||
if (!usageUpdate) return
|
||||
usage = {
|
||||
...usage,
|
||||
...usageUpdate,
|
||||
cache_creation: {
|
||||
...usage?.cache_creation,
|
||||
...usageUpdate.cache_creation,
|
||||
},
|
||||
server_tool_use: {
|
||||
...usage?.server_tool_use,
|
||||
...usageUpdate.server_tool_use,
|
||||
},
|
||||
}
|
||||
},
|
||||
getStreamUsage: () => usage,
|
||||
normalizeUsage: (usage: Usage) => ({
|
||||
inputTokens: usage.input_tokens ?? 0,
|
||||
outputTokens: usage.output_tokens ?? 0,
|
||||
cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
|
||||
cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
|
||||
cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
|
||||
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
|
||||
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
|
||||
export async function OPTIONS(input: APIEvent) {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(input: APIEvent) {
|
||||
const zenData = ZenData.list()
|
||||
const disabledModels = await authenticate()
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
object: "list",
|
||||
data: Object.entries(zenData.models)
|
||||
.filter(([id]) => !disabledModels.includes(id))
|
||||
.map(([id, model]) => ({
|
||||
id: `opencode/${id}`,
|
||||
object: "model",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: "opencode",
|
||||
})),
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async function authenticate() {
|
||||
const apiKey = input.request.headers.get("authorization")?.split(" ")[1]
|
||||
if (!apiKey) return []
|
||||
|
||||
const disabledModels = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
model: ModelTable.model,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
||||
.leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted)))
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows.map((row) => row.model)),
|
||||
)
|
||||
|
||||
return disabledModels
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,9 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { handler } from "~/routes/zen/handler"
|
||||
|
||||
type Usage = {
|
||||
input_tokens?: number
|
||||
input_tokens_details?: {
|
||||
cached_tokens?: number
|
||||
}
|
||||
output_tokens?: number
|
||||
output_tokens_details?: {
|
||||
reasoning_tokens?: number
|
||||
}
|
||||
total_tokens?: number
|
||||
}
|
||||
import { handler } from "~/routes/zen/util/handler"
|
||||
|
||||
export function POST(input: APIEvent) {
|
||||
let usage: Usage
|
||||
return handler(input, {
|
||||
setAuthHeader: (headers: Headers, apiKey: string) => {
|
||||
headers.set("authorization", `Bearer ${apiKey}`)
|
||||
},
|
||||
format: "openai",
|
||||
parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
|
||||
onStreamPart: (chunk: string) => {
|
||||
const [event, data] = chunk.split("\n")
|
||||
if (event !== "event: response.completed") return
|
||||
if (!data.startsWith("data: ")) return
|
||||
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!json.response?.usage) return
|
||||
usage = json.response.usage
|
||||
},
|
||||
getStreamUsage: () => usage,
|
||||
normalizeUsage: (usage: Usage) => {
|
||||
const inputTokens = usage.input_tokens ?? 0
|
||||
const outputTokens = usage.output_tokens ?? 0
|
||||
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
|
||||
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
|
||||
return {
|
||||
inputTokens: inputTokens - (cacheReadTokens ?? 0),
|
||||
outputTokens: outputTokens - (reasoningTokens ?? 0),
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.svg" />
|
||||
<title>OpenCode</title>
|
||||
</head>
|
||||
<body class="overscroll-none select-none text-12-regular">
|
||||
<body class="antialiased overscroll-none select-none text-12-regular">
|
||||
<!-- <script> -->
|
||||
<!-- ;(function () { -->
|
||||
<!-- const savedTheme = localStorage.getItem("theme") || "opencode" -->
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"opencode": "workspace:*",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
"vite-plugin-solid": "catalog:"
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import type { Part, AssistantMessage, ReasoningPart, TextPart, ToolPart } from "@opencode-ai/sdk"
|
||||
import { children, Component, createMemo, For, Match, Show, Switch, type JSX } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { Markdown } from "./markdown"
|
||||
import { Checkbox, Collapsible, Diff, Icon, IconProps } from "@opencode-ai/ui"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import type { Tool } from "opencode/tool/tool"
|
||||
import type { ReadTool } from "opencode/tool/read"
|
||||
import type { ListTool } from "opencode/tool/ls"
|
||||
import type { GlobTool } from "opencode/tool/glob"
|
||||
import type { GrepTool } from "opencode/tool/grep"
|
||||
import type { WebFetchTool } from "opencode/tool/webfetch"
|
||||
import type { TaskTool } from "opencode/tool/task"
|
||||
import type { BashTool } from "opencode/tool/bash"
|
||||
import type { EditTool } from "opencode/tool/edit"
|
||||
import type { WriteTool } from "opencode/tool/write"
|
||||
import type { TodoWriteTool } from "opencode/tool/todo"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
|
||||
export function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; lastToolOnly?: boolean }) {
|
||||
const filteredParts = createMemo(() => {
|
||||
let tool = false
|
||||
return props.parts?.filter((x) => {
|
||||
if (x.type === "tool" && props.lastToolOnly && tool) return false
|
||||
if (x.type === "tool") tool = true
|
||||
return x.type !== "tool" || x.tool !== "todoread"
|
||||
})
|
||||
})
|
||||
return (
|
||||
<div class="w-full flex flex-col items-start gap-4">
|
||||
<For each={filteredParts()}>
|
||||
{(part) => {
|
||||
const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
|
||||
return (
|
||||
<Show when={component()}>
|
||||
<Dynamic component={component()} part={part as any} message={props.message} />
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PART_MAPPING = {
|
||||
text: TextPart,
|
||||
tool: ToolPart,
|
||||
reasoning: ReasoningPart,
|
||||
}
|
||||
|
||||
function ReasoningPart(props: { part: ReasoningPart; message: AssistantMessage }) {
|
||||
return null
|
||||
// return (
|
||||
// <Show when={props.part.text.trim()}>
|
||||
// <div>{props.part.text}</div>
|
||||
// </Show>
|
||||
// )
|
||||
}
|
||||
|
||||
function TextPart(props: { part: TextPart; message: AssistantMessage }) {
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<Markdown text={props.part.text.trim()} />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
|
||||
// const sync = useSync()
|
||||
|
||||
const component = createMemo(() => {
|
||||
const render = ToolRegistry.render(props.part.tool) ?? GenericTool
|
||||
|
||||
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
|
||||
const input = props.part.state.status === "completed" ? props.part.state.input : {}
|
||||
// const permissions = sync.data.permission[props.message.sessionID] ?? []
|
||||
// const permissionIndex = permissions.findIndex((x) => x.callID === props.part.callID)
|
||||
// const permission = permissions[permissionIndex]
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={render}
|
||||
input={input}
|
||||
tool={props.part.tool}
|
||||
metadata={metadata}
|
||||
// permission={permission?.metadata ?? {}}
|
||||
output={props.part.state.status === "completed" ? props.part.state.output : undefined}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return <Show when={component()}>{component()}</Show>
|
||||
}
|
||||
|
||||
type TriggerTitle = {
|
||||
title: string
|
||||
titleClass?: string
|
||||
subtitle?: string
|
||||
subtitleClass?: string
|
||||
args?: string[]
|
||||
argsClass?: string
|
||||
action?: JSX.Element
|
||||
}
|
||||
|
||||
const isTriggerTitle = (val: any): val is TriggerTitle => {
|
||||
return typeof val === "object" && val !== null && "title" in val && !(val instanceof Node)
|
||||
}
|
||||
|
||||
function BasicTool(props: { icon: IconProps["name"]; trigger: TriggerTitle | JSX.Element; children?: JSX.Element }) {
|
||||
const resolved = children(() => props.children)
|
||||
return (
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger>
|
||||
<div class="w-full flex items-center self-stretch gap-5 justify-between">
|
||||
<div class="w-full flex items-center self-stretch gap-5">
|
||||
<Icon name={props.icon} size="small" class="shrink-0" />
|
||||
<div class="grow min-w-0">
|
||||
<Switch>
|
||||
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
||||
{(trigger) => (
|
||||
<div class="w-full flex items-center gap-2 justify-between">
|
||||
<div class="flex items-center gap-2 whitespace-nowrap truncate">
|
||||
<span
|
||||
classList={{
|
||||
"text-12-medium text-text-base": true,
|
||||
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
||||
}}
|
||||
>
|
||||
{trigger().title}
|
||||
</span>
|
||||
<Show when={trigger().subtitle}>
|
||||
<span
|
||||
classList={{
|
||||
"text-12-medium text-text-weak": true,
|
||||
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
|
||||
}}
|
||||
>
|
||||
{trigger().subtitle}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={trigger().args?.length}>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
classList={{
|
||||
"text-12-regular text-text-weak": true,
|
||||
[trigger().argsClass ?? ""]: !!trigger().argsClass,
|
||||
}}
|
||||
>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={trigger().action}>{trigger().action}</Show>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={resolved()}>
|
||||
<Collapsible.Arrow />
|
||||
</Show>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={props.children}>
|
||||
<Collapsible.Content>{props.children}</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
// <>
|
||||
// <Show when={props.part.state.status === "error"}>{props.part.state.error.replace("Error: ", "")}</Show>
|
||||
// </>
|
||||
)
|
||||
}
|
||||
|
||||
function GenericTool(props: ToolProps<any>) {
|
||||
return <BasicTool icon="mcp" trigger={{ title: props.tool }} />
|
||||
}
|
||||
|
||||
type ToolProps<T extends Tool.Info> = {
|
||||
input: Partial<Tool.InferParameters<T>>
|
||||
metadata: Partial<Tool.InferMetadata<T>>
|
||||
// permission: Record<string, any>
|
||||
tool: string
|
||||
output?: string
|
||||
}
|
||||
|
||||
const ToolRegistry = (() => {
|
||||
const state: Record<
|
||||
string,
|
||||
{
|
||||
name: string
|
||||
render?: Component<ToolProps<any>>
|
||||
}
|
||||
> = {}
|
||||
function register<T extends Tool.Info>(input: { name: string; render?: Component<ToolProps<T>> }) {
|
||||
state[input.name] = input
|
||||
return input
|
||||
}
|
||||
return {
|
||||
register,
|
||||
render(name: string) {
|
||||
return state[name]?.render
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
ToolRegistry.register<typeof ReadTool>({
|
||||
name: "read",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="glasses"
|
||||
trigger={{ title: "Read", subtitle: props.input.filePath ? getFilename(props.input.filePath) : "" }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof ListTool>({
|
||||
name: "list",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof GlobTool>({
|
||||
name: "glob",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="magnifying-glass-menu"
|
||||
trigger={{
|
||||
title: "Glob",
|
||||
subtitle: getDirectory(props.input.path || "/"),
|
||||
args: props.input.pattern ? ["pattern=" + props.input.pattern] : [],
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof GrepTool>({
|
||||
name: "grep",
|
||||
render(props) {
|
||||
const args = []
|
||||
if (props.input.pattern) args.push("pattern=" + props.input.pattern)
|
||||
if (props.input.include) args.push("include=" + props.input.include)
|
||||
return (
|
||||
<BasicTool
|
||||
icon="magnifying-glass-menu"
|
||||
trigger={{
|
||||
title: "Grep",
|
||||
subtitle: getDirectory(props.input.path || "/"),
|
||||
args,
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof WebFetchTool>({
|
||||
name: "webfetch",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="window-cursor"
|
||||
trigger={{
|
||||
title: "Webfetch",
|
||||
subtitle: props.input.url || "",
|
||||
args: props.input.format ? ["format=" + props.input.format] : [],
|
||||
action: (
|
||||
<div class="size-6 flex items-center justify-center">
|
||||
<Icon name="square-arrow-top-right" size="small" />
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof TaskTool>({
|
||||
name: "task",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="task"
|
||||
trigger={{
|
||||
title: `${props.input.subagent_type || props.tool} Agent`,
|
||||
titleClass: "capitalize",
|
||||
subtitle: props.input.description,
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof BashTool>({
|
||||
name: "bash",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="console"
|
||||
trigger={{
|
||||
title: "Shell",
|
||||
subtitle: "Ran " + props.input.command,
|
||||
}}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof EditTool>({
|
||||
name: "edit",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-12-medium text-text-base capitalize">Edit</div>
|
||||
<div class="flex">
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<span class="text-text-weak">{getDirectory(props.input.filePath!)}</span>
|
||||
</Show>
|
||||
<span class="text-text-strong">{getFilename(props.input.filePath ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 items-center justify-end">
|
||||
<Show when={props.metadata.filediff}>
|
||||
<DiffChanges diff={props.metadata.filediff} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.metadata.filediff}>
|
||||
<div class="border-t border-border-weaker-base">
|
||||
<Diff
|
||||
before={{ name: getFilename(props.metadata.filediff.path), contents: props.metadata.filediff.before }}
|
||||
after={{ name: getFilename(props.metadata.filediff.path), contents: props.metadata.filediff.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof WriteTool>({
|
||||
name: "write",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="code-lines"
|
||||
trigger={
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-12-medium text-text-base capitalize">Write</div>
|
||||
<div class="flex">
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<span class="text-text-weak">{getDirectory(props.input.filePath!)}</span>
|
||||
</Show>
|
||||
<span class="text-text-strong">{getFilename(props.input.filePath ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 items-center justify-end">{/* <DiffChanges diff={diff} /> */}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={false && props.output}>
|
||||
<div class="whitespace-pre">{props.output}</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
ToolRegistry.register<typeof TodoWriteTool>({
|
||||
name: "todowrite",
|
||||
render(props) {
|
||||
return (
|
||||
<BasicTool
|
||||
icon="checklist"
|
||||
trigger={{
|
||||
title: "To-dos",
|
||||
subtitle: `${props.input.todos?.filter((t) => t.status === "completed").length}/${props.input.todos?.length}`,
|
||||
}}
|
||||
>
|
||||
<Show when={props.input.todos?.length}>
|
||||
<div class="px-12 pt-2.5 pb-6 flex flex-col gap-2">
|
||||
<For each={props.input.todos}>
|
||||
{(todo) => (
|
||||
<Checkbox readOnly checked={todo.status === "completed"}>
|
||||
<div classList={{ "line-through text-text-weaker": todo.status === "completed" }}>{todo.content}</div>
|
||||
</Checkbox>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { FileDiff } from "@opencode-ai/sdk"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
|
||||
export function DiffChanges(props: { diff: FileDiff | FileDiff[] }) {
|
||||
const additions = createMemo(() =>
|
||||
Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) : props.diff.additions,
|
||||
)
|
||||
const deletions = createMemo(() =>
|
||||
Array.isArray(props.diff) ? props.diff.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) : props.diff.deletions,
|
||||
)
|
||||
const total = createMemo(() => additions() + deletions())
|
||||
return (
|
||||
<Show when={total() > 0}>
|
||||
<div class="flex gap-2 justify-end items-center">
|
||||
<span class="text-12-mono text-right text-text-diff-add-base">{`+${additions()}`}</span>
|
||||
<span class="text-12-mono text-right text-text-diff-delete-base">{`-${deletions()}`}</span>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -10,13 +10,15 @@ import { DateTime } from "luxon"
|
||||
|
||||
interface PartBase {
|
||||
content: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface TextPart extends PartBase {
|
||||
export interface TextPart extends PartBase {
|
||||
type: "text"
|
||||
}
|
||||
|
||||
interface FileAttachmentPart extends PartBase {
|
||||
export interface FileAttachmentPart extends PartBase {
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
@@ -34,7 +36,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const local = useLocal()
|
||||
let editorRef!: HTMLDivElement
|
||||
|
||||
const defaultParts = [{ type: "text", content: "" } as const]
|
||||
const defaultParts = [{ type: "text", content: "", start: 0, end: 0 } as const]
|
||||
const [store, setStore] = createStore<{
|
||||
contentParts: ContentPart[]
|
||||
popoverIsOpen: boolean
|
||||
@@ -51,7 +53,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
event.stopPropagation()
|
||||
// @ts-expect-error
|
||||
const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? ""
|
||||
addPart({ type: "text", content: plainText })
|
||||
addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -74,7 +76,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
key: (x) => x,
|
||||
onSelect: (path) => {
|
||||
if (!path) return
|
||||
addPart({ type: "file", path, content: "@" + getFilename(path) })
|
||||
addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
|
||||
setStore("popoverIsOpen", false)
|
||||
},
|
||||
})
|
||||
@@ -117,17 +119,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const parseFromDOM = (): ContentPart[] => {
|
||||
const newParts: ContentPart[] = []
|
||||
let position = 0
|
||||
editorRef.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
if (node.textContent) newParts.push({ type: "text", content: node.textContent })
|
||||
if (node.textContent) {
|
||||
const content = node.textContent
|
||||
newParts.push({ type: "text", content, start: position, end: position + content.length })
|
||||
position += content.length
|
||||
}
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) {
|
||||
switch ((node as HTMLElement).dataset.type) {
|
||||
case "file":
|
||||
const content = node.textContent!
|
||||
newParts.push({
|
||||
type: "file",
|
||||
path: (node as HTMLElement).dataset.path!,
|
||||
content: node.textContent!,
|
||||
content,
|
||||
start: position,
|
||||
end: position + content.length,
|
||||
})
|
||||
position += content.length
|
||||
break
|
||||
default:
|
||||
break
|
||||
@@ -163,17 +174,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const startIndex = atMatch ? atMatch.index! : cursorPosition
|
||||
const endIndex = atMatch ? cursorPosition : cursorPosition
|
||||
|
||||
const pushText = (acc: { parts: ContentPart[] }, value: string) => {
|
||||
const pushText = (acc: { parts: ContentPart[]; runningIndex: number }, value: string) => {
|
||||
if (!value) return
|
||||
const last = acc.parts[acc.parts.length - 1]
|
||||
if (last && last.type === "text") {
|
||||
acc.parts[acc.parts.length - 1] = {
|
||||
type: "text",
|
||||
content: last.content + value,
|
||||
start: last.start,
|
||||
end: last.end + value.length,
|
||||
}
|
||||
return
|
||||
}
|
||||
acc.parts.push({ type: "text", content: value })
|
||||
acc.parts.push({ type: "text", content: value, start: acc.runningIndex, end: acc.runningIndex + value.length })
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -183,20 +196,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
} = store.contentParts.reduce(
|
||||
(acc, item) => {
|
||||
if (acc.inserted) {
|
||||
acc.parts.push(item)
|
||||
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
|
||||
acc.runningIndex += item.content.length
|
||||
return acc
|
||||
}
|
||||
|
||||
const nextIndex = acc.runningIndex + item.content.length
|
||||
if (nextIndex <= startIndex) {
|
||||
acc.parts.push(item)
|
||||
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
|
||||
acc.runningIndex = nextIndex
|
||||
return acc
|
||||
}
|
||||
|
||||
if (item.type !== "text") {
|
||||
acc.parts.push(item)
|
||||
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
|
||||
acc.runningIndex = nextIndex
|
||||
return acc
|
||||
}
|
||||
@@ -207,24 +220,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const tail = item.content.slice(tailLength)
|
||||
|
||||
pushText(acc, head)
|
||||
acc.runningIndex += head.length
|
||||
|
||||
if (part.type === "text") {
|
||||
pushText(acc, part.content)
|
||||
acc.runningIndex += part.content.length
|
||||
}
|
||||
if (part.type !== "text") {
|
||||
acc.parts.push({ ...part })
|
||||
acc.parts.push({ ...part, start: acc.runningIndex, end: acc.runningIndex + part.content.length })
|
||||
acc.runningIndex += part.content.length
|
||||
}
|
||||
|
||||
const needsGap = Boolean(atMatch)
|
||||
const rest = needsGap ? (tail ? (/^\s/.test(tail) ? tail : ` ${tail}`) : " ") : tail
|
||||
pushText(acc, rest)
|
||||
acc.runningIndex += rest.length
|
||||
|
||||
const baseCursor = startIndex + part.content.length
|
||||
const cursorAddition = needsGap && rest.length > 0 ? 1 : 0
|
||||
acc.cursorPositionAfter = baseCursor + cursorAddition
|
||||
|
||||
acc.inserted = true
|
||||
acc.runningIndex = nextIndex
|
||||
return acc
|
||||
},
|
||||
{
|
||||
@@ -237,9 +253,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
if (!inserted) {
|
||||
const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
|
||||
const appendedAcc = { parts: [...baseParts] as ContentPart[] }
|
||||
if (part.type === "text") pushText(appendedAcc, part.content)
|
||||
if (part.type !== "text") appendedAcc.parts.push({ ...part })
|
||||
const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0)
|
||||
const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex }
|
||||
if (part.type === "text") {
|
||||
pushText(appendedAcc, part.content)
|
||||
}
|
||||
if (part.type !== "text") {
|
||||
appendedAcc.parts.push({
|
||||
...part,
|
||||
start: appendedAcc.runningIndex,
|
||||
end: appendedAcc.runningIndex + part.content.length,
|
||||
})
|
||||
}
|
||||
const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts
|
||||
setStore("contentParts", next)
|
||||
setStore("popoverIsOpen", false)
|
||||
@@ -289,7 +314,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}/
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
</div>
|
||||
|
||||
@@ -429,13 +429,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
.sort((a, b) => b.id.localeCompare(a.id)),
|
||||
)
|
||||
|
||||
const working = createMemo(() => {
|
||||
const last = messages()[messages().length - 1]
|
||||
if (!last) return false
|
||||
if (last.role === "user") return true
|
||||
return !last.time.completed
|
||||
})
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
messages(),
|
||||
@@ -460,13 +453,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage)
|
||||
})
|
||||
|
||||
const activeAssistantMessages = createMemo(() => {
|
||||
if (!store.active || !activeMessage()) return []
|
||||
return sync.data.message[store.active]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == activeMessage()?.id,
|
||||
)
|
||||
})
|
||||
|
||||
const model = createMemo(() => {
|
||||
if (!last()) return
|
||||
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
|
||||
@@ -494,6 +480,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const getMessageText = (message: Message | Message[] | undefined): string => {
|
||||
if (!message) return ""
|
||||
if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ")
|
||||
const fileParts = sync.data.part[message.id]?.filter((p) => p.type === "file")
|
||||
console.log(fileParts)
|
||||
|
||||
return sync.data.part[message.id]
|
||||
?.filter((p) => p.type === "text")
|
||||
?.filter((p) => !p.synthetic)
|
||||
@@ -504,7 +493,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return {
|
||||
active,
|
||||
activeMessage,
|
||||
activeAssistantMessages,
|
||||
lastUserMessage,
|
||||
cost,
|
||||
last,
|
||||
@@ -514,7 +502,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
messages,
|
||||
messagesWithValidParts,
|
||||
userMessages,
|
||||
working,
|
||||
// working,
|
||||
getMessageText,
|
||||
setActive(sessionId: string | undefined) {
|
||||
setStore("active", sessionId)
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { Button, List, SelectDialog, Tooltip, IconButton, Tabs, Icon, Accordion } from "@opencode-ai/ui"
|
||||
import {
|
||||
Button,
|
||||
List,
|
||||
SelectDialog,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Icon,
|
||||
Accordion,
|
||||
Diff,
|
||||
Collapsible,
|
||||
} from "@opencode-ai/ui"
|
||||
import { FileIcon } from "@/ui"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
|
||||
import { useLocal, type LocalFile, type TextSelection } from "@/context/local"
|
||||
import { useLocal, type LocalFile } from "@/context/local"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { ContentPart, PromptInput } from "@/components/prompt-input"
|
||||
@@ -21,7 +32,10 @@ import type { JSX } from "solid-js"
|
||||
import { Code } from "@/components/code"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Diff } from "@/components/diff"
|
||||
import { ProgressCircle } from "@/components/progress-circle"
|
||||
import { AssistantMessage } from "@/components/assistant-message"
|
||||
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||
import { DiffChanges } from "@/components/diff-changes"
|
||||
|
||||
export default function Page() {
|
||||
const local = useLocal()
|
||||
@@ -92,7 +106,7 @@ export default function Page() {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key.length === 1 && event.key !== "Unidentified") {
|
||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||
inputRef?.focus()
|
||||
}
|
||||
}
|
||||
@@ -182,42 +196,10 @@ export default function Page() {
|
||||
}
|
||||
if (!session) return
|
||||
|
||||
interface SubmissionAttachment {
|
||||
path: string
|
||||
selection?: TextSelection
|
||||
label: string
|
||||
}
|
||||
|
||||
const createAttachmentKey = (path: string, selection?: TextSelection) => {
|
||||
if (!selection) return path
|
||||
return `${path}:${selection.startLine}:${selection.startChar}:${selection.endLine}:${selection.endChar}`
|
||||
}
|
||||
|
||||
const formatAttachmentLabel = (path: string, selection?: TextSelection) => {
|
||||
if (!selection) return getFilename(path)
|
||||
return `${getFilename(path)} (${selection.startLine}-${selection.endLine})`
|
||||
}
|
||||
|
||||
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
|
||||
|
||||
const text = parts.map((part) => part.content).join("")
|
||||
const attachments = new Map<string, SubmissionAttachment>()
|
||||
|
||||
const registerAttachment = (path: string, selection: TextSelection | undefined, label?: string) => {
|
||||
if (!path) return
|
||||
const key = createAttachmentKey(path, selection)
|
||||
if (attachments.has(key)) return
|
||||
attachments.set(key, {
|
||||
path,
|
||||
selection,
|
||||
label: label ?? formatAttachmentLabel(path, selection),
|
||||
})
|
||||
}
|
||||
|
||||
const promptAttachments = parts.filter((part) => part.type === "file")
|
||||
for (const part of promptAttachments) {
|
||||
registerAttachment(part.path, part.selection, part.content)
|
||||
}
|
||||
const attachments = parts.filter((part) => part.type === "file")
|
||||
|
||||
// const activeFile = local.context.active()
|
||||
// if (activeFile) {
|
||||
@@ -236,7 +218,7 @@ export default function Page() {
|
||||
// )
|
||||
// }
|
||||
|
||||
const attachmentParts = Array.from(attachments.values()).map((attachment) => {
|
||||
const attachmentParts = attachments.map((attachment) => {
|
||||
const absolute = toAbsolutePath(attachment.path)
|
||||
const query = attachment.selection
|
||||
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
|
||||
@@ -249,9 +231,9 @@ export default function Page() {
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: `@${attachment.label}`,
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path: absolute,
|
||||
},
|
||||
@@ -392,9 +374,6 @@ export default function Page() {
|
||||
{(session) => {
|
||||
const diffs = createMemo(() => session.summary?.diffs ?? [])
|
||||
const filesChanged = createMemo(() => diffs().length)
|
||||
const additions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.additions ?? 0), 0))
|
||||
const deletions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.deletions ?? 0), 0))
|
||||
|
||||
return (
|
||||
<Tooltip placement="right" value={session.title}>
|
||||
<div>
|
||||
@@ -408,12 +387,7 @@ export default function Page() {
|
||||
</div>
|
||||
<div class="flex justify-between items-center self-stretch">
|
||||
<span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span>
|
||||
<Show when={additions() || deletions()}>
|
||||
<div class="flex gap-2 justify-end items-center">
|
||||
<span class="text-12-mono text-right text-text-diff-add-base">{`+${additions()}`}</span>
|
||||
<span class="text-12-mono text-right text-text-diff-delete-base">{`-${deletions()}`}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<DiffChanges diff={diffs()} />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -434,13 +408,12 @@ export default function Page() {
|
||||
<Tabs onChange={handleTabChange}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="chat" class="flex gap-x-1.5 items-center">
|
||||
<Tabs.Trigger value="chat" class="flex gap-x-4 items-center">
|
||||
<div>Chat</div>
|
||||
<Show when={local.session.active()}>
|
||||
<div class="flex flex-col h-4 px-2 -mr-2 justify-center items-center rounded-full bg-surface-base text-12-medium text-text-strong">
|
||||
{local.session.context()}%
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value={`${local.session.tokens() ?? 0} Tokens`} class="flex items-center gap-1.5">
|
||||
<ProgressCircle percentage={local.session.context() ?? 0} />
|
||||
<div class="text-14-regular text-text-weak text-right">{local.session.context() ?? 0}%</div>
|
||||
</Tooltip>
|
||||
</Tabs.Trigger>
|
||||
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
||||
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
|
||||
@@ -516,8 +489,8 @@ export default function Page() {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0">
|
||||
<div class="p-6 pt-12 max-w-[904px] w-full mx-auto flex flex-col flex-1 min-h-0">
|
||||
<Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
|
||||
<div class="px-6 pt-12 max-w-[904px] w-full mx-auto flex flex-col flex-1 min-h-0">
|
||||
<Show
|
||||
when={local.session.active()}
|
||||
fallback={
|
||||
@@ -526,7 +499,7 @@ export default function Page() {
|
||||
<div class="flex justify-center items-center gap-3">
|
||||
<Icon name="folder" size="small" />
|
||||
<div class="text-12-medium text-text-weak">
|
||||
{getDirectory(sync.data.path.directory)}/
|
||||
{getDirectory(sync.data.path.directory)}
|
||||
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -543,100 +516,218 @@ export default function Page() {
|
||||
}
|
||||
>
|
||||
{(activeSession) => (
|
||||
<div class="py-3 flex flex-col flex-1 min-h-0">
|
||||
<div class="pt-3 flex flex-col flex-1 min-h-0">
|
||||
<div class="flex items-start gap-8 flex-1 min-h-0">
|
||||
<Show when={local.session.userMessages().length > 1}>
|
||||
<ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1">
|
||||
<For each={local.session.userMessages()}>
|
||||
{(message) => (
|
||||
<li
|
||||
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
|
||||
onClick={() => local.session.setActiveMessage(message.id)}
|
||||
>
|
||||
<div class="w-[18px] shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
|
||||
<g>
|
||||
<rect x="0" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="4" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="8" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="12" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="16" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
data-active={local.session.activeMessage()?.id === message.id}
|
||||
classList={{
|
||||
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
||||
}}
|
||||
{(message) => {
|
||||
const countLines = (text: string) => {
|
||||
if (!text) return 0
|
||||
return text.split("\n").length
|
||||
}
|
||||
|
||||
const additions = createMemo(
|
||||
() =>
|
||||
message.summary?.diffs.reduce((acc, diff) => acc + (diff.additions ?? 0), 0) ?? 0,
|
||||
)
|
||||
|
||||
const deletions = createMemo(
|
||||
() =>
|
||||
message.summary?.diffs.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0) ?? 0,
|
||||
)
|
||||
|
||||
const totalBeforeLines = createMemo(
|
||||
() =>
|
||||
message.summary?.diffs.reduce((acc, diff) => acc + countLines(diff.before), 0) ??
|
||||
0,
|
||||
)
|
||||
|
||||
const blockCounts = createMemo(() => {
|
||||
const TOTAL_BLOCKS = 5
|
||||
|
||||
const adds = additions()
|
||||
const dels = deletions()
|
||||
const unchanged = Math.max(0, totalBeforeLines() - dels)
|
||||
|
||||
const totalActivity = unchanged + adds + dels
|
||||
|
||||
if (totalActivity === 0) {
|
||||
return { added: 0, deleted: 0, neutral: TOTAL_BLOCKS }
|
||||
}
|
||||
|
||||
const percentAdded = adds / totalActivity
|
||||
const percentDeleted = dels / totalActivity
|
||||
const added_raw = percentAdded * TOTAL_BLOCKS
|
||||
const deleted_raw = percentDeleted * TOTAL_BLOCKS
|
||||
|
||||
let added = adds > 0 ? Math.ceil(added_raw) : 0
|
||||
let deleted = dels > 0 ? Math.ceil(deleted_raw) : 0
|
||||
|
||||
let total_allocated = added + deleted
|
||||
if (total_allocated > TOTAL_BLOCKS) {
|
||||
if (added_raw < deleted_raw) {
|
||||
added = Math.floor(added_raw)
|
||||
} else {
|
||||
deleted = Math.floor(deleted_raw)
|
||||
}
|
||||
|
||||
total_allocated = added + deleted
|
||||
if (total_allocated > TOTAL_BLOCKS) {
|
||||
if (added_raw < deleted_raw) {
|
||||
deleted = Math.floor(deleted_raw)
|
||||
} else {
|
||||
added = Math.floor(added_raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const neutral = Math.max(0, TOTAL_BLOCKS - added - deleted)
|
||||
|
||||
return { added, deleted, neutral }
|
||||
})
|
||||
|
||||
const ADD_COLOR = "var(--icon-diff-add-base)"
|
||||
const DELETE_COLOR = "var(--icon-diff-delete-base)"
|
||||
const NEUTRAL_COLOR = "var(--icon-weak-base)"
|
||||
|
||||
const visibleBlocks = createMemo(() => {
|
||||
const counts = blockCounts()
|
||||
const blocks = [
|
||||
...Array(counts.added).fill(ADD_COLOR),
|
||||
...Array(counts.deleted).fill(DELETE_COLOR),
|
||||
...Array(counts.neutral).fill(NEUTRAL_COLOR),
|
||||
]
|
||||
return blocks.slice(0, 5)
|
||||
})
|
||||
|
||||
return (
|
||||
<li
|
||||
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
|
||||
onClick={() => local.session.setActiveMessage(message.id)}
|
||||
>
|
||||
{message.summary?.title ?? local.session.getMessageText(message)}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
<div class="w-[18px] shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
|
||||
<g>
|
||||
<For each={visibleBlocks()}>
|
||||
{(color, i) => (
|
||||
<rect x={i() * 4} width="2" height="12" rx="1" fill={color} />
|
||||
)}
|
||||
</For>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
data-active={local.session.activeMessage()?.id === message.id}
|
||||
classList={{
|
||||
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
||||
}}
|
||||
>
|
||||
{message.summary?.title ?? local.session.getMessageText(message)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
<div ref={messageScrollElement} class="grow min-w-0 h-full overflow-y-auto no-scrollbar">
|
||||
<div class="flex flex-col items-start gap-50 pb-[800px]">
|
||||
<div class="flex flex-col items-start gap-50 pb-50">
|
||||
<For each={local.session.userMessages()}>
|
||||
{(message) => {
|
||||
const title = createMemo(() => message.summary?.title)
|
||||
const prompt = createMemo(() => local.session.getMessageText(message))
|
||||
const summary = createMemo(() => message.summary?.body)
|
||||
const assistantMessages = createMemo(() => {
|
||||
return sync.data.message[activeSession().id]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||
) as AssistantMessageType[]
|
||||
})
|
||||
const working = createMemo(() => {
|
||||
const last = assistantMessages()[assistantMessages().length - 1]
|
||||
if (!last) return false
|
||||
return !last.time.completed
|
||||
})
|
||||
const lastWithContent = createMemo(() =>
|
||||
assistantMessages().findLast((m) => {
|
||||
const parts = sync.data.part[m.id]
|
||||
return parts?.find((p) => p.type === "text" || p.type === "tool")
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-message={message.id}
|
||||
class="flex flex-col items-start self-stretch gap-14 pt-1.5"
|
||||
>
|
||||
<div data-message={message.id} class="flex flex-col items-start self-stretch gap-8">
|
||||
{/* Title */}
|
||||
<div class="flex flex-col items-start gap-2 self-stretch">
|
||||
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger">
|
||||
<h1 class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
|
||||
{title() ?? prompt()}
|
||||
</h1>
|
||||
<Show when={title}>
|
||||
<div class="text-12-regular text-text-base">{prompt()}</div>
|
||||
</div>
|
||||
<Show when={title}>
|
||||
<div class="-mt-5 text-12-regular text-text-base line-clamp-3">{prompt()}</div>
|
||||
</Show>
|
||||
{/* Response */}
|
||||
<div class="w-full flex flex-col gap-2">
|
||||
<Collapsible variant="ghost">
|
||||
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
||||
<div class="flex items-center gap-1 self-stretch">
|
||||
<h2 class="text-12-medium">Show steps</h2>
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div class="w-full flex flex-col items-start self-stretch gap-8">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(() => sync.data.part[assistantMessage.id])
|
||||
return <AssistantMessage message={assistantMessage} parts={parts()} />
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
<Show when={working() && lastWithContent()}>
|
||||
{(last) => {
|
||||
const lastParts = createMemo(() => sync.data.part[last().id])
|
||||
return (
|
||||
<AssistantMessage lastToolOnly message={last()} parts={lastParts()} />
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
{/* Summary */}
|
||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||
<Show when={summary}>
|
||||
<Show when={!working()}>
|
||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<h2 class="text-12-medium text-text-weak">Summary</h2>
|
||||
<div class="text-14-regular text-text-base self-stretch">{summary()}</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={message.summary?.diffs.length}>
|
||||
<Accordion class="w-full" multiple>
|
||||
<For each={message.summary?.diffs || []}>
|
||||
{(diff) => (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger onClick={handleDiffTriggerClick}>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center justify-between w-full gap-5">
|
||||
<div class="grow flex items-center gap-5 min-w-0">
|
||||
<FileIcon
|
||||
node={{ path: diff.file, type: "file" }}
|
||||
class="shrink-0 size-4"
|
||||
/>
|
||||
<div class="flex">
|
||||
<div class="flex grow min-w-0">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span class="text-text-base">
|
||||
{getDirectory(diff.file)}/
|
||||
<span class="text-text-base truncate-start">
|
||||
{getDirectory(diff.file)}‎
|
||||
</span>
|
||||
</Show>
|
||||
<span class="text-text-strong">
|
||||
<span class="text-text-strong shrink-0">
|
||||
{getFilename(diff.file)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 items-center justify-end">
|
||||
<div class="flex gap-2 justify-end items-center">
|
||||
<span class="text-12-mono text-right text-text-diff-add-base">{`+${diff.additions}`}</span>
|
||||
<span class="text-12-mono text-right text-text-diff-delete-base">{`-${diff.deletions}`}</span>
|
||||
</div>
|
||||
<div class="shrink-0 flex gap-4 items-center justify-end">
|
||||
<DiffChanges diff={diff} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -658,14 +749,8 @@ export default function Page() {
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
</Show>
|
||||
</div>
|
||||
{/* Response */}
|
||||
<div data-todo="Response (Timeline)">
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<h2 class="text-12-medium text-text-weak">Response</h2>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
@@ -772,7 +857,7 @@ export default function Page() {
|
||||
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
|
||||
<div class="flex items-center text-14-regular">
|
||||
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
|
||||
{getDirectory(i)}/
|
||||
{getDirectory(i)}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
export function getFilename(path: string) {
|
||||
if (!path) return ""
|
||||
const trimmed = path.replace(/[\/]+$/, "")
|
||||
@@ -6,8 +8,10 @@ export function getFilename(path: string) {
|
||||
}
|
||||
|
||||
export function getDirectory(path: string) {
|
||||
const sync = useSync()
|
||||
const parts = path.split("/")
|
||||
return parts.slice(0, parts.length - 1).join("/")
|
||||
const dir = parts.slice(0, parts.length - 1).join("/")
|
||||
return dir ? sync.sanitize(dir + "/") : ""
|
||||
}
|
||||
|
||||
export function getFileExtension(path: string) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#!/usr/bin/env bun
|
||||
import path from "path"
|
||||
const dir = new URL("..", import.meta.url).pathname
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
import { $ } from "bun"
|
||||
|
||||
|
||||
@@ -40,16 +40,35 @@ for (const [name] of Object.entries(binaries)) {
|
||||
}
|
||||
await $`cd ./dist/${pkg.name} && bun publish --access public --tag ${Script.channel}`
|
||||
|
||||
if (!Script.preview) {
|
||||
const major = Script.version.split(".")[0]
|
||||
const majorTag = `latest-${major}`
|
||||
for (const [name] of Object.entries(binaries)) {
|
||||
await $`cd dist/${name} && npm dist-tag add ${name}@${Script.version} ${majorTag}`
|
||||
}
|
||||
await $`cd ./dist/${pkg.name} && npm dist-tag add ${pkg.name}-ai@${Script.version} ${majorTag}`
|
||||
}
|
||||
|
||||
if (!Script.preview) {
|
||||
for (const key of Object.keys(binaries)) {
|
||||
await $`cd dist/${key}/bin && zip -r ../../${key}.zip *`
|
||||
}
|
||||
|
||||
// Calculate SHA values
|
||||
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`
|
||||
.text()
|
||||
.then((x) => x.trim())
|
||||
|
||||
const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2)
|
||||
|
||||
// arch
|
||||
const binaryPkgbuild = [
|
||||
@@ -57,7 +76,8 @@ if (!Script.preview) {
|
||||
"# Maintainer: adam",
|
||||
"",
|
||||
"pkgname='opencode-bin'",
|
||||
`pkgver=${Script.version.split("-")[0]}`,
|
||||
`pkgver=${pkgver}`,
|
||||
`_subver=${_subver}`,
|
||||
"options=('!debug' '!strip')",
|
||||
"pkgrel=1",
|
||||
"pkgdesc='The AI coding agent built for the terminal.'",
|
||||
@@ -68,10 +88,10 @@ if (!Script.preview) {
|
||||
"conflicts=('opencode')",
|
||||
"depends=('fzf' 'ripgrep')",
|
||||
"",
|
||||
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-arm64.zip")`,
|
||||
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.zip::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.zip")`,
|
||||
`sha256sums_aarch64=('${arm64Sha}')`,
|
||||
"",
|
||||
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v${Script.version}/opencode-linux-x64.zip")`,
|
||||
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.zip::https://github.com/sst/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.zip")`,
|
||||
`sha256sums_x86_64=('${x64Sha}')`,
|
||||
"",
|
||||
"package() {",
|
||||
@@ -86,7 +106,8 @@ if (!Script.preview) {
|
||||
"# Maintainer: adam",
|
||||
"",
|
||||
"pkgname='opencode'",
|
||||
`pkgver=${Script.version.split("-")[0]}`,
|
||||
`pkgver=${pkgver}`,
|
||||
`_subver=${_subver}`,
|
||||
"options=('!debug' '!strip')",
|
||||
"pkgrel=1",
|
||||
"pkgdesc='The AI coding agent built for the terminal.'",
|
||||
@@ -98,7 +119,7 @@ if (!Script.preview) {
|
||||
"depends=('fzf' 'ripgrep')",
|
||||
"makedepends=('git' 'bun-bin' 'go')",
|
||||
"",
|
||||
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v${Script.version}.tar.gz")`,
|
||||
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
|
||||
`sha256sums=('SKIP')`,
|
||||
"",
|
||||
"build() {",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { z } from "zod/v4"
|
||||
import { z } from "zod"
|
||||
import { Config } from "../src/config/config"
|
||||
|
||||
const file = process.argv[2]
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import type {
|
||||
Agent as ACPAgent,
|
||||
AgentSideConnection,
|
||||
AuthenticateRequest,
|
||||
CancelNotification,
|
||||
InitializeRequest,
|
||||
LoadSessionRequest,
|
||||
NewSessionRequest,
|
||||
PermissionOption,
|
||||
PromptRequest,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
import {
|
||||
type Agent as ACPAgent,
|
||||
type AgentSideConnection,
|
||||
type AuthenticateRequest,
|
||||
type CancelNotification,
|
||||
type InitializeRequest,
|
||||
type LoadSessionRequest,
|
||||
type NewSessionRequest,
|
||||
type PermissionOption,
|
||||
type PlanEntry,
|
||||
type PromptRequest,
|
||||
type SetSessionModelRequest,
|
||||
type SetSessionModeRequest,
|
||||
type SetSessionModeResponse,
|
||||
type ToolCallContent,
|
||||
type ToolKind,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Log } from "../util/log"
|
||||
import { ACPSessionManager } from "./session"
|
||||
@@ -25,24 +28,17 @@ import { Storage } from "@/storage/storage"
|
||||
import { Command } from "@/command"
|
||||
import { Agent as Agents } from "@/agent/agent"
|
||||
import { Permission } from "@/permission"
|
||||
import { Session } from "@/session"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import type { Config } from "@/config/config"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { z } from "zod"
|
||||
|
||||
export namespace ACP {
|
||||
const log = Log.create({ service: "acp-agent" })
|
||||
|
||||
// TODO: mcp servers?
|
||||
|
||||
type ToolKind =
|
||||
| "read"
|
||||
| "edit"
|
||||
| "delete"
|
||||
| "move"
|
||||
| "search"
|
||||
| "execute"
|
||||
| "think"
|
||||
| "fetch"
|
||||
| "switch_mode"
|
||||
| "other"
|
||||
|
||||
export class Agent implements ACPAgent {
|
||||
private sessionManager = new ACPSessionManager()
|
||||
private connection: AgentSideConnection
|
||||
@@ -157,6 +153,62 @@ export namespace ACP {
|
||||
})
|
||||
break
|
||||
case "completed":
|
||||
const kind = toToolKind(part.tool)
|
||||
const content: ToolCallContent[] = [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
}
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
|
||||
if (parsedTodos.success) {
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId: acpSession.id,
|
||||
update: {
|
||||
sessionUpdate: "plan",
|
||||
entries: parsedTodos.data.map((todo) => {
|
||||
const status: PlanEntry["status"] =
|
||||
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
|
||||
return {
|
||||
priority: "medium",
|
||||
status,
|
||||
content: todo.content,
|
||||
}
|
||||
}),
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("failed to send session update for todo", { error: err })
|
||||
})
|
||||
} else {
|
||||
log.error("failed to parse todo output", { error: parsedTodos.error })
|
||||
}
|
||||
}
|
||||
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId: acpSession.id,
|
||||
@@ -164,15 +216,8 @@ export namespace ACP {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: part.callID,
|
||||
status: "completed",
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.state.output,
|
||||
},
|
||||
},
|
||||
],
|
||||
kind,
|
||||
content,
|
||||
title: part.state.title,
|
||||
rawOutput: {
|
||||
output: part.state.output,
|
||||
@@ -258,11 +303,14 @@ export namespace ACP {
|
||||
protocolVersion: 1,
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
// TODO: map acp mcp
|
||||
// mcpCapabilities: {
|
||||
// http: true,
|
||||
// sse: true,
|
||||
// },
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
promptCapabilities: {
|
||||
embeddedContext: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
authMethods: [
|
||||
{
|
||||
@@ -287,6 +335,7 @@ export namespace ACP {
|
||||
const model = await defaultModel(this.config)
|
||||
const session = await this.sessionManager.create(params.cwd, params.mcpServers, model)
|
||||
|
||||
log.info("creating_session", { mcpServers: params.mcpServers.length })
|
||||
const load = await this.loadSession({
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers,
|
||||
@@ -325,6 +374,17 @@ export namespace ACP {
|
||||
name: command.name,
|
||||
description: command.description ?? "",
|
||||
}))
|
||||
const names = new Set(availableCommands.map((c) => c.name))
|
||||
if (!names.has("init"))
|
||||
availableCommands.push({
|
||||
name: "init",
|
||||
description: "create/update a AGENTS.md",
|
||||
})
|
||||
if (!names.has("compact"))
|
||||
availableCommands.push({
|
||||
name: "compact",
|
||||
description: "compact the session",
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
this.connection.sessionUpdate({
|
||||
@@ -346,6 +406,35 @@ export namespace ACP {
|
||||
|
||||
const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
|
||||
|
||||
const mcpServers: Record<string, Config.Mcp> = {}
|
||||
for (const server of params.mcpServers) {
|
||||
if ("type" in server) {
|
||||
mcpServers[server.name] = {
|
||||
url: server.url,
|
||||
headers: server.headers.reduce<Record<string, string>>((acc, { name, value }) => {
|
||||
acc[name] = value
|
||||
return acc
|
||||
}, {}),
|
||||
type: "remote",
|
||||
}
|
||||
} else {
|
||||
mcpServers[server.name] = {
|
||||
type: "local",
|
||||
command: [server.command, ...server.args],
|
||||
environment: server.env.reduce<Record<string, string>>((acc, { name, value }) => {
|
||||
acc[name] = value
|
||||
return acc
|
||||
}, {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(mcpServers).map(async ([key, mcp]) => {
|
||||
await MCP.add(key, mcp)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models: {
|
||||
@@ -452,25 +541,25 @@ export namespace ACP {
|
||||
|
||||
log.info("parts", { parts })
|
||||
|
||||
const cmd = await (async () => {
|
||||
const text = parts.filter((part) => part.type === "text").join("")
|
||||
const match = text.match(/^\/(\w+)\s*(.*)$/)
|
||||
if (!match) return
|
||||
const cmd = (() => {
|
||||
const text = parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("")
|
||||
.trim()
|
||||
|
||||
const [c, args] = match.slice(1)
|
||||
const command = await Command.get(c)
|
||||
if (!command) return
|
||||
return { command, args }
|
||||
if (!text.startsWith("/")) return
|
||||
|
||||
const [name, ...rest] = text.slice(1).split(/\s+/)
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
})()
|
||||
|
||||
if (cmd) {
|
||||
await SessionPrompt.command({
|
||||
sessionID,
|
||||
command: cmd.command.name,
|
||||
arguments: cmd.args,
|
||||
agent,
|
||||
})
|
||||
} else {
|
||||
const done = {
|
||||
stopReason: "end_turn" as const,
|
||||
_meta: {},
|
||||
}
|
||||
|
||||
if (!cmd) {
|
||||
await SessionPrompt.prompt({
|
||||
sessionID,
|
||||
model: {
|
||||
@@ -480,12 +569,40 @@ export namespace ACP {
|
||||
parts,
|
||||
agent,
|
||||
})
|
||||
return done
|
||||
}
|
||||
|
||||
return {
|
||||
stopReason: "end_turn" as const,
|
||||
_meta: {},
|
||||
const command = await Command.get(cmd.name)
|
||||
if (command) {
|
||||
await SessionPrompt.command({
|
||||
sessionID,
|
||||
command: command.name,
|
||||
arguments: cmd.args,
|
||||
model: model.providerID + "/" + model.modelID,
|
||||
agent,
|
||||
})
|
||||
return done
|
||||
}
|
||||
|
||||
switch (cmd.name) {
|
||||
case "init":
|
||||
await Session.initialize({
|
||||
sessionID,
|
||||
messageID: Identifier.ascending("message"),
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
})
|
||||
break
|
||||
case "compact":
|
||||
await SessionCompaction.run({
|
||||
sessionID,
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
return done
|
||||
}
|
||||
|
||||
async cancel(params: CancelNotification) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Config } from "../config/config"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { generateObject, type ModelMessage } from "ai"
|
||||
import PROMPT_GENERATE from "./generate.txt"
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import z from "zod/v4"
|
||||
import { Auth } from "./index"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace AuthGithubCopilot {
|
||||
const CLIENT_ID = "Iv1.b507a08c87ecfe98"
|
||||
const DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||
const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
||||
const COPILOT_API_KEY_URL = "https://api.github.com/copilot_internal/v2/token"
|
||||
|
||||
interface DeviceCodeResponse {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
interface AccessTokenResponse {
|
||||
access_token?: string
|
||||
error?: string
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
interface CopilotTokenResponse {
|
||||
token: string
|
||||
expires_at: number
|
||||
refresh_in: number
|
||||
endpoints: {
|
||||
api: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function authorize() {
|
||||
const deviceResponse = await fetch(DEVICE_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID,
|
||||
scope: "read:user",
|
||||
}),
|
||||
})
|
||||
const deviceData: DeviceCodeResponse = await deviceResponse.json()
|
||||
return {
|
||||
device: deviceData.device_code,
|
||||
user: deviceData.user_code,
|
||||
verification: deviceData.verification_uri,
|
||||
interval: deviceData.interval || 5,
|
||||
expiry: deviceData.expires_in,
|
||||
}
|
||||
}
|
||||
|
||||
export async function poll(device_code: string) {
|
||||
const response = await fetch(ACCESS_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID,
|
||||
device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) return "failed"
|
||||
|
||||
const data: AccessTokenResponse = await response.json()
|
||||
|
||||
if (data.access_token) {
|
||||
// Store the GitHub OAuth token
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
refresh: data.access_token,
|
||||
access: "",
|
||||
expires: 0,
|
||||
})
|
||||
return "complete"
|
||||
}
|
||||
|
||||
if (data.error === "authorization_pending") return "pending"
|
||||
|
||||
if (data.error) return "failed"
|
||||
|
||||
return "pending"
|
||||
}
|
||||
|
||||
export async function access() {
|
||||
const info = await Auth.get("github-copilot")
|
||||
if (!info || info.type !== "oauth") return
|
||||
if (info.access && info.expires > Date.now()) return info.access
|
||||
|
||||
// Get new Copilot API token
|
||||
const response = await fetch(COPILOT_API_KEY_URL, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${info.refresh}`,
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
"Editor-Version": "vscode/1.99.3",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const tokenData: CopilotTokenResponse = await response.json()
|
||||
|
||||
// Store the Copilot API token
|
||||
await Auth.set("github-copilot", {
|
||||
type: "oauth",
|
||||
refresh: info.refresh,
|
||||
access: tokenData.token,
|
||||
expires: tokenData.expires_at * 1000,
|
||||
})
|
||||
|
||||
return tokenData.token
|
||||
}
|
||||
|
||||
export const DeviceCodeError = NamedError.create("DeviceCodeError", z.object({}))
|
||||
|
||||
export const TokenExchangeError = NamedError.create(
|
||||
"TokenExchangeError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const AuthenticationError = NamedError.create(
|
||||
"AuthenticationError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const CopilotTokenError = NamedError.create(
|
||||
"CopilotTokenError",
|
||||
z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Auth {
|
||||
export const Oauth = z
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import z from "zod/v4"
|
||||
import type { ZodType } from "zod/v4"
|
||||
import z from "zod"
|
||||
import type { ZodType } from "zod"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export const AcpCommand = cmd({
|
||||
const input = new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(Buffer.from(chunk), (err) => {
|
||||
process.stdout.write(chunk, (err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EOL } from "os"
|
||||
import { Config } from "../../../config/config"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -8,7 +9,7 @@ export const ConfigCommand = cmd({
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const config = await Config.get()
|
||||
console.log(JSON.stringify(config, null, 2))
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ const FileSearchCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await File.search({ query: args.query })
|
||||
console.log(results.join(EOL))
|
||||
process.stdout.write(results.join(EOL) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -30,7 +30,7 @@ const FileReadCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const content = await File.read(args.path)
|
||||
console.log(content)
|
||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -41,7 +41,7 @@ const FileStatusCommand = cmd({
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const status = await File.status()
|
||||
console.log(JSON.stringify(status, null, 2))
|
||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -57,7 +57,7 @@ const FileListCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const files = await File.list(args.path)
|
||||
console.log(JSON.stringify(files, null, 2))
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LSP } from "../../../lsp"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Log } from "../../../util/log"
|
||||
import { EOL } from "os"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
@@ -16,7 +17,7 @@ const DiagnosticsCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
console.log(JSON.stringify(await LSP.diagnostics(), null, 2))
|
||||
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -28,7 +29,7 @@ export const SymbolsCommand = cmd({
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = await LSP.workspaceSymbol(args.query)
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -40,7 +41,7 @@ export const DocumentSymbolsCommand = cmd({
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = await LSP.documentSymbol(args.uri)
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ const TreeCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit }))
|
||||
process.stdout.write(await Ripgrep.tree({ cwd: Instance.directory, limit: args.limit }) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -49,7 +49,7 @@ const FilesCommand = cmd({
|
||||
files.push(file)
|
||||
if (args.limit && files.length >= args.limit) break
|
||||
}
|
||||
console.log(files.join(EOL))
|
||||
process.stdout.write(files.join(EOL) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -78,6 +78,6 @@ const SearchCommand = cmd({
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
})
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EOL } from "os"
|
||||
import { Project } from "../../../project/project"
|
||||
import { Log } from "../../../util/log"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -8,7 +9,7 @@ export const ScrapCommand = cmd({
|
||||
async handler() {
|
||||
const timer = Log.Default.time("scrap")
|
||||
const list = await Project.list()
|
||||
console.log(list)
|
||||
process.stdout.write(JSON.stringify(list, null, 2) + EOL)
|
||||
timer.stop()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -78,6 +78,10 @@ export const RunCommand = cmd({
|
||||
array: true,
|
||||
describe: "file(s) to attach to message",
|
||||
})
|
||||
.option("title", {
|
||||
type: "string",
|
||||
describe: "title for the session (uses truncated prompt if no value provided)",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
let message = args.message.join(" ")
|
||||
@@ -143,7 +147,19 @@ export const RunCommand = cmd({
|
||||
|
||||
if (args.session) return Session.get(args.session)
|
||||
|
||||
return Session.create({})
|
||||
const title = (() => {
|
||||
if (args.title !== undefined) {
|
||||
if (args.title === "") {
|
||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||
}
|
||||
return args.title
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
|
||||
return Session.create({
|
||||
title,
|
||||
})
|
||||
})()
|
||||
|
||||
if (!session) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { EOL } from "os"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { mergeDeep, pipe } from "remeda"
|
||||
@@ -61,6 +61,11 @@ export namespace Config {
|
||||
)),
|
||||
]
|
||||
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
directories.push(Flag.OPENCODE_CONFIG_DIR)
|
||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
for (const dir of directories) {
|
||||
await assertValid(dir)
|
||||
installDependencies(dir)
|
||||
@@ -581,6 +586,7 @@ export namespace Config {
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"),
|
||||
disable_paste_summary: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { $ } from "bun"
|
||||
import type { BunFile } from "bun"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
import { $ } from "bun"
|
||||
@@ -218,7 +218,7 @@ export namespace Ripgrep {
|
||||
code: "ENOENT",
|
||||
errno: -2,
|
||||
path: input.cwd,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(args, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export namespace Flag {
|
||||
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
|
||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||
export const OPENCODE_CONFIG_DIR = process.env["OPENCODE_CONFIG_DIR"]
|
||||
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
|
||||
@@ -9,6 +10,7 @@ export namespace Flag {
|
||||
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
|
||||
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
|
||||
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
|
||||
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
|
||||
|
||||
// Experimental
|
||||
export const OPENCODE_EXPERIMENTAL_WATCHER = truthy("OPENCODE_EXPERIMENTAL_WATCHER")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
export namespace Identifier {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn } from "bun"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Log } from "../util/log"
|
||||
import { Bus } from "../bus"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "path"
|
||||
import { $ } from "bun"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types
|
||||
import { Log } from "../util/log"
|
||||
import { LANGUAGE_EXTENSIONS } from "./language"
|
||||
import { Bus } from "../bus"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import type { LSPServer } from "./server"
|
||||
import { NamedError } from "../util/error"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Log } from "../util/log"
|
||||
import { LSPClient } from "./client"
|
||||
import path from "path"
|
||||
import { LSPServer } from "./server"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { spawn } from "child_process"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
+138
-110
@@ -5,7 +5,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
import { NamedError } from "../util/error"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Session } from "../session"
|
||||
import { Bus } from "../bus"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -26,122 +26,22 @@ export namespace MCP {
|
||||
const state = Instance.state(
|
||||
async () => {
|
||||
const cfg = await Config.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
const clients: {
|
||||
[name: string]: MCPClient
|
||||
} = {}
|
||||
for (const [key, mcp] of Object.entries(cfg.mcp ?? {})) {
|
||||
if (mcp.enabled === false) {
|
||||
log.info("mcp server disabled", { key })
|
||||
continue
|
||||
}
|
||||
log.info("found", { key, type: mcp.type })
|
||||
if (mcp.type === "remote") {
|
||||
const transports = [
|
||||
{
|
||||
name: "StreamableHTTP",
|
||||
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
|
||||
requestInit: {
|
||||
headers: mcp.headers,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "SSE",
|
||||
transport: new SSEClientTransport(new URL(mcp.url), {
|
||||
requestInit: {
|
||||
headers: mcp.headers,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
let lastError: Error | undefined
|
||||
for (const { name, transport } of transports) {
|
||||
const client = await experimental_createMCPClient({
|
||||
name: "opencode",
|
||||
transport,
|
||||
}).catch((error) => {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
log.debug("transport connection failed", {
|
||||
key,
|
||||
transport: name,
|
||||
url: mcp.url,
|
||||
error: lastError.message,
|
||||
})
|
||||
return null
|
||||
})
|
||||
if (client) {
|
||||
log.debug("transport connection succeeded", { key, transport: name })
|
||||
clients[key] = client
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!clients[key]) {
|
||||
const errorMessage = lastError
|
||||
? `MCP server ${key} failed to connect: ${lastError.message}`
|
||||
: `MCP server ${key} failed to connect to ${mcp.url}`
|
||||
log.error("remote mcp connection failed", { key, url: mcp.url, error: lastError?.message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: errorMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (mcp.type === "local") {
|
||||
const [cmd, ...args] = mcp.command
|
||||
const client = await experimental_createMCPClient({
|
||||
name: "opencode",
|
||||
transport: new StdioClientTransport({
|
||||
stderr: "ignore",
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
...process.env,
|
||||
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
...mcp.environment,
|
||||
},
|
||||
}),
|
||||
}).catch((error) => {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? `MCP server ${key} failed to start: ${error.message}`
|
||||
: `MCP server ${key} failed to start`
|
||||
log.error("local mcp startup failed", {
|
||||
key,
|
||||
command: mcp.command,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: errorMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
return null
|
||||
})
|
||||
if (client) {
|
||||
clients[key] = client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, client] of Object.entries(clients)) {
|
||||
const result = await withTimeout(client.tools(), 5000).catch(() => {})
|
||||
if (!result) {
|
||||
log.warn("mcp client verification failed, removing client", { key })
|
||||
delete clients[key]
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Object.entries(config).map(async ([key, mcp]) => {
|
||||
const result = await create(key, mcp).catch(() => undefined)
|
||||
if (!result) return
|
||||
clients[key] = result.client
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
clients,
|
||||
config: cfg.mcp ?? {},
|
||||
config,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
@@ -151,6 +51,133 @@ export namespace MCP {
|
||||
},
|
||||
)
|
||||
|
||||
export async function add(name: string, mcp: Config.Mcp) {
|
||||
const s = await state()
|
||||
const result = await create(name, mcp)
|
||||
if (!result) return
|
||||
s.clients[name] = result.client
|
||||
}
|
||||
|
||||
async function create(name: string, mcp: Config.Mcp) {
|
||||
if (mcp.enabled === false) {
|
||||
log.info("mcp server disabled", { name })
|
||||
return
|
||||
}
|
||||
log.info("found", { name, type: mcp.type })
|
||||
|
||||
let mcpClient: MCPClient | undefined
|
||||
|
||||
if (mcp.type === "remote") {
|
||||
const transports = [
|
||||
{
|
||||
name: "StreamableHTTP",
|
||||
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
|
||||
requestInit: {
|
||||
headers: mcp.headers,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "SSE",
|
||||
transport: new SSEClientTransport(new URL(mcp.url), {
|
||||
requestInit: {
|
||||
headers: mcp.headers,
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
let lastError: Error | undefined
|
||||
for (const { name, transport } of transports) {
|
||||
const client = await experimental_createMCPClient({
|
||||
name: "opencode",
|
||||
transport,
|
||||
}).catch((error) => {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
log.debug("transport connection failed", {
|
||||
name,
|
||||
transport: name,
|
||||
url: mcp.url,
|
||||
error: lastError.message,
|
||||
})
|
||||
return null
|
||||
})
|
||||
if (client) {
|
||||
log.debug("transport connection succeeded", { name, transport: name })
|
||||
mcpClient = client
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!mcpClient) {
|
||||
const errorMessage = lastError
|
||||
? `MCP server ${name} failed to connect: ${lastError.message}`
|
||||
: `MCP server ${name} failed to connect to ${mcp.url}`
|
||||
log.error("remote mcp connection failed", { name, url: mcp.url, error: lastError?.message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: errorMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (mcp.type === "local") {
|
||||
const [cmd, ...args] = mcp.command
|
||||
const client = await experimental_createMCPClient({
|
||||
name: "opencode",
|
||||
transport: new StdioClientTransport({
|
||||
stderr: "ignore",
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
...process.env,
|
||||
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
...mcp.environment,
|
||||
},
|
||||
}),
|
||||
}).catch((error) => {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? `MCP server ${name} failed to start: ${error.message}`
|
||||
: `MCP server ${name} failed to start`
|
||||
log.error("local mcp startup failed", {
|
||||
name,
|
||||
command: mcp.command,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: errorMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
return null
|
||||
})
|
||||
if (client) {
|
||||
mcpClient = client
|
||||
}
|
||||
}
|
||||
|
||||
if (!mcpClient) {
|
||||
log.warn("mcp client not initialized", { name })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await withTimeout(mcpClient.tools(), 5000).catch(() => {})
|
||||
if (!result) {
|
||||
log.warn("mcp client verification failed, dropping client", { name })
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
client: mcpClient,
|
||||
}
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return state().then((state) => {
|
||||
const result: Record<string, "connected" | "failed" | "disabled"> = {}
|
||||
@@ -161,6 +188,7 @@ export namespace MCP {
|
||||
}
|
||||
if (state.clients[key]) {
|
||||
result[key] = "connected"
|
||||
continue
|
||||
}
|
||||
result[key] = "failed"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
import { Identifier } from "../id/id"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import path from "path"
|
||||
import { $ } from "bun"
|
||||
import { Storage } from "../storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
export namespace Project {
|
||||
const log = Log.create({ service: "project" })
|
||||
@@ -31,6 +32,7 @@ export namespace Project {
|
||||
const project: Info = {
|
||||
id: "global",
|
||||
worktree: "/",
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
time: {
|
||||
created: Date.now(),
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { data } from "./models-macro" with { type: "macro" }
|
||||
import { Installation } from "../installation"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { Config } from "../config/config"
|
||||
import { mergeDeep, sortBy } from "remeda"
|
||||
@@ -101,7 +101,7 @@ export namespace Provider {
|
||||
"nova-pro",
|
||||
"nova-premier",
|
||||
"claude",
|
||||
"deepseek"
|
||||
"deepseek",
|
||||
].some((m) => modelID.includes(m))
|
||||
const isGovCloud = region.startsWith("us-gov")
|
||||
if (modelRequiresPrefix && !isGovCloud) {
|
||||
@@ -517,14 +517,11 @@ export namespace Provider {
|
||||
|
||||
const provider = await state().then((state) => state.providers[providerID])
|
||||
if (!provider) return
|
||||
const priority = [
|
||||
"claude-haiku-4-5",
|
||||
"claude-haiku-4.5",
|
||||
"3-5-haiku",
|
||||
"3.5-haiku",
|
||||
"gemini-2.5-flash",
|
||||
"gpt-5-nano",
|
||||
]
|
||||
let priority = ["claude-haiku-4-5", "claude-haiku-4.5", "3-5-haiku", "3.5-haiku", "gemini-2.5-flash", "gpt-5-nano"]
|
||||
// claude-haiku-4.5 is considered a premium model in github copilot, we shouldn't use premium requests for title gen
|
||||
if (providerID === "github-copilot") {
|
||||
priority = priority.filter((m) => m !== "claude-haiku-4.5")
|
||||
}
|
||||
for (const item of priority) {
|
||||
for (const model of Object.keys(provider.info.models)) {
|
||||
if (model.includes(item)) return getModel(providerID, model)
|
||||
|
||||
@@ -75,7 +75,7 @@ export namespace ProviderTransform {
|
||||
|
||||
export function temperature(_providerID: string, modelID: string) {
|
||||
if (modelID.toLowerCase().includes("qwen")) return 0.55
|
||||
if (modelID.toLowerCase().includes("claude")) return 1
|
||||
if (modelID.toLowerCase().includes("claude")) return undefined
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -92,7 +92,9 @@ export namespace ProviderTransform {
|
||||
}
|
||||
|
||||
if (modelID.includes("gpt-5") && !modelID.includes("gpt-5-chat")) {
|
||||
if (!modelID.includes("codex")) result["reasoningEffort"] = "medium"
|
||||
if (!modelID.includes("codex") && !modelID.includes("gpt-5-pro")) {
|
||||
result["reasoningEffort"] = "medium"
|
||||
}
|
||||
|
||||
if (providerID !== "azure") {
|
||||
result["textVerbosity"] = modelID.includes("codex") ? "medium" : "low"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Hono } from "hono"
|
||||
import { cors } from "hono/cors"
|
||||
import { stream, streamSSE } from "hono/streaming"
|
||||
import { Session } from "../session"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { mapValues } from "remeda"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
@@ -7,7 +7,7 @@ import { defer } from "../util/defer"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Bus } from "../bus"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import type { ModelsDev } from "../provider/models"
|
||||
import { SessionPrompt } from "./prompt"
|
||||
import { Flag } from "../flag/flag"
|
||||
@@ -16,6 +16,7 @@ import { Log } from "../util/log"
|
||||
import { SessionLock } from "./lock"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { SessionRetry } from "./retry"
|
||||
import { Config } from "@/config/config"
|
||||
|
||||
export namespace SessionCompaction {
|
||||
const log = Log.create({ service: "session.compaction" })
|
||||
@@ -155,6 +156,7 @@ export namespace SessionCompaction {
|
||||
error,
|
||||
})
|
||||
},
|
||||
tools: model.info.tool_call ? {} : undefined,
|
||||
messages: [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
@@ -188,7 +190,11 @@ export namespace SessionCompaction {
|
||||
case "text-delta":
|
||||
part.text += value.text
|
||||
if (value.providerMetadata) part.metadata = value.providerMetadata
|
||||
if (part.text) await Session.updatePart(part)
|
||||
if (part.text)
|
||||
await Session.updatePart({
|
||||
part,
|
||||
delta: value.text,
|
||||
})
|
||||
continue
|
||||
case "text-end": {
|
||||
part.text = part.text.trimEnd()
|
||||
@@ -253,12 +259,14 @@ export namespace SessionCompaction {
|
||||
}
|
||||
|
||||
let stream = doStream()
|
||||
const cfg = await Config.get()
|
||||
const maxRetries = cfg.experimental?.chatMaxRetries ?? MAX_RETRIES
|
||||
let result = await process(stream, {
|
||||
count: 0,
|
||||
max: MAX_RETRIES,
|
||||
max: maxRetries,
|
||||
})
|
||||
if (result.shouldRetry) {
|
||||
for (let retry = 1; retry < MAX_RETRIES; retry++) {
|
||||
for (let retry = 1; retry < maxRetries; retry++) {
|
||||
const lastRetryPart = result.parts.findLast((p) => p.type === "retry")
|
||||
|
||||
if (lastRetryPart) {
|
||||
@@ -295,7 +303,7 @@ export namespace SessionCompaction {
|
||||
stream = doStream()
|
||||
result = await process(stream, {
|
||||
count: retry,
|
||||
max: MAX_RETRIES,
|
||||
max: maxRetries,
|
||||
})
|
||||
if (!result.shouldRetry) {
|
||||
break
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Decimal } from "decimal.js"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { type LanguageModelUsage, type ProviderMetadata } from "ai"
|
||||
|
||||
import PROMPT_INITIALIZE from "../session/prompt/initialize.txt"
|
||||
@@ -23,11 +23,17 @@ import { Snapshot } from "@/snapshot"
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
|
||||
const parentSessionTitlePrefix = "New session - "
|
||||
const childSessionTitlePrefix = "Child session - "
|
||||
const parentTitlePrefix = "New session - "
|
||||
const childTitlePrefix = "Child session - "
|
||||
|
||||
function createDefaultTitle(isChild = false) {
|
||||
return (isChild ? childSessionTitlePrefix : parentSessionTitlePrefix) + new Date().toISOString()
|
||||
return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString()
|
||||
}
|
||||
|
||||
export function isDefaultTitle(title: string) {
|
||||
return new RegExp(
|
||||
`^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`,
|
||||
).test(title)
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
@@ -78,6 +84,12 @@ export namespace Session {
|
||||
export type ShareInfo = z.output<typeof ShareInfo>
|
||||
|
||||
export const Event = {
|
||||
Created: Bus.event(
|
||||
"session.created",
|
||||
z.object({
|
||||
info: Info,
|
||||
}),
|
||||
),
|
||||
Updated: Bus.event(
|
||||
"session.updated",
|
||||
z.object({
|
||||
@@ -167,6 +179,9 @@ export namespace Session {
|
||||
}
|
||||
log.info("created", result)
|
||||
await Storage.write(["session", Instance.project.id, result.id], result)
|
||||
Bus.publish(Event.Created, {
|
||||
info: result,
|
||||
})
|
||||
const cfg = await Config.get()
|
||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
|
||||
share(result.id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Log } from "../util/log"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { NamedError } from "../util/error"
|
||||
import { Message } from "./message"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace Message {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Identifier } from "../id/id"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Log } from "../util/log"
|
||||
@@ -50,6 +50,7 @@ import { Command } from "../command"
|
||||
import { $, fileURLToPath } from "bun"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { SessionSummary } from "./summary"
|
||||
import { Config } from "@/config/config"
|
||||
|
||||
export namespace SessionPrompt {
|
||||
const log = Log.create({ service: "session.prompt" })
|
||||
@@ -191,28 +192,6 @@ export namespace SessionPrompt {
|
||||
processor,
|
||||
})
|
||||
|
||||
// const permUnsub = (() => {
|
||||
// const handled = new Set<string>()
|
||||
// const options = [
|
||||
// { optionId: "allow_once", kind: "allow_once", name: "Allow once" },
|
||||
// { optionId: "allow_always", kind: "allow_always", name: "Always allow" },
|
||||
// { optionId: "reject_once", kind: "reject_once", name: "Reject" },
|
||||
// ]
|
||||
// return Bus.subscribe(Permission.Event.Updated, async (event) => {
|
||||
// const info = event.properties
|
||||
// if (info.sessionID !== input.sessionID) return
|
||||
// if (handled.has(info.id)) return
|
||||
// handled.add(info.id)
|
||||
// const toolCallId = info.callID ?? info.id
|
||||
// const metadata = info.metadata ?? {}
|
||||
// // TODO: emit permission event to bus for ACP to handle
|
||||
// Permission.respond({ sessionID: info.sessionID, permissionID: info.id, response: "reject" })
|
||||
// })
|
||||
// })()
|
||||
// await using _permSub = defer(() => {
|
||||
// permUnsub?.()
|
||||
// })
|
||||
|
||||
const params = await Plugin.trigger(
|
||||
"chat.params",
|
||||
{
|
||||
@@ -352,12 +331,14 @@ export namespace SessionPrompt {
|
||||
})
|
||||
|
||||
let stream = doStream()
|
||||
const cfg = await Config.get()
|
||||
const maxRetries = cfg.experimental?.chatMaxRetries ?? MAX_RETRIES
|
||||
let result = await processor.process(stream, {
|
||||
count: 0,
|
||||
max: MAX_RETRIES,
|
||||
max: maxRetries,
|
||||
})
|
||||
if (result.shouldRetry) {
|
||||
for (let retry = 1; retry < MAX_RETRIES; retry++) {
|
||||
for (let retry = 1; retry < maxRetries; retry++) {
|
||||
const lastRetryPart = result.parts.findLast((p) => p.type === "retry")
|
||||
|
||||
if (lastRetryPart) {
|
||||
@@ -394,7 +375,7 @@ export namespace SessionPrompt {
|
||||
stream = doStream()
|
||||
result = await processor.process(stream, {
|
||||
count: retry,
|
||||
max: MAX_RETRIES,
|
||||
max: maxRetries,
|
||||
})
|
||||
if (!result.shouldRetry) {
|
||||
break
|
||||
@@ -604,7 +585,17 @@ export namespace SessionPrompt {
|
||||
args,
|
||||
},
|
||||
)
|
||||
const result = await execute(args, opts)
|
||||
const result = await execute(args, opts).catch((err: unknown) => {
|
||||
log.error("Error executing tool", { error: err, tool: key })
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to execute tool: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
await Plugin.trigger(
|
||||
"tool.execute.after",
|
||||
@@ -1712,6 +1703,7 @@ export namespace SessionPrompt {
|
||||
modelID: string
|
||||
}) {
|
||||
if (input.session.parentID) return
|
||||
if (!Session.isDefaultTitle(input.session.title)) return
|
||||
const isFirst =
|
||||
input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic))
|
||||
.length === 1
|
||||
|
||||
@@ -93,6 +93,8 @@ user: What is the codebase structure?
|
||||
assistant: [Uses the Task tool]
|
||||
</example>
|
||||
|
||||
IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
|
||||
|
||||
# Code References
|
||||
|
||||
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
|
||||
@@ -86,7 +86,7 @@ export namespace SessionSummary {
|
||||
) {
|
||||
const result = await generateText({
|
||||
model: small.language,
|
||||
maxOutputTokens: 50,
|
||||
maxOutputTokens: 100,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Storage } from "../storage/storage"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Log } from "../util/log"
|
||||
import { Global } from "../global"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { spawn } from "child_process"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./bash.txt"
|
||||
@@ -27,7 +27,9 @@ const parser = lazy(async () => {
|
||||
return p
|
||||
} catch (e) {
|
||||
const { default: Parser } = await import("web-tree-sitter")
|
||||
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, { with: { type: "wasm" } })
|
||||
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
|
||||
with: { type: "wasm" },
|
||||
})
|
||||
await Parser.init({
|
||||
locateFile() {
|
||||
return treeWasm
|
||||
@@ -55,6 +57,11 @@ export const BashTool = Tool.define("bash", {
|
||||
),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
if (params.timeout !== undefined && params.timeout < 0) {
|
||||
throw new Error(
|
||||
`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`,
|
||||
)
|
||||
}
|
||||
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT)
|
||||
const tree = await parser().then((p) => p.parse(params.command))
|
||||
const permissions = await Agent.get(ctx.agent).then((x) => x.permission.bash)
|
||||
@@ -97,7 +104,10 @@ export const BashTool = Tool.define("bash", {
|
||||
|
||||
// always allow cd if it passes above check
|
||||
if (command[0] !== "cd") {
|
||||
const action = Wildcard.allStructured({ head: command[0], tail: command.slice(1) }, permissions)
|
||||
const action = Wildcard.allStructured(
|
||||
{ head: command[0], tail: command.slice(1) },
|
||||
permissions,
|
||||
)
|
||||
if (action === "deny") {
|
||||
throw new Error(
|
||||
`The user has specifically restricted access to this command, you are not allowed to execute it. Here is the configuration: ${JSON.stringify(permissions)}`,
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
|
||||
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
|
||||
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Permission } from "../permission"
|
||||
import DESCRIPTION from "./edit.txt"
|
||||
import { File } from "../file"
|
||||
@@ -16,6 +16,7 @@ import { FileTime } from "../file/time"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
|
||||
export const EditTool = Tool.define("edit", {
|
||||
description: DESCRIPTION,
|
||||
@@ -114,10 +115,23 @@ export const EditTool = Tool.define("edit", {
|
||||
}
|
||||
}
|
||||
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file: filePath,
|
||||
before: contentOld,
|
||||
after: contentNew,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
for (const change of diffLines(contentOld, contentNew)) {
|
||||
if (change.added) filediff.additions += change.count || 0
|
||||
if (change.removed) filediff.deletions += change.count || 0
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics,
|
||||
diff,
|
||||
filediff,
|
||||
},
|
||||
title: `${path.relative(Instance.worktree, filePath)}`,
|
||||
output,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const InvalidTool = Tool.define("invalid", {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import * as path from "path"
|
||||
import DESCRIPTION from "./ls.txt"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import path from "path"
|
||||
import { LSP } from "../lsp"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import path from "path"
|
||||
import { LSP } from "../lsp"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { EditTool } from "./edit"
|
||||
import DESCRIPTION from "./multiedit.txt"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
@@ -17,7 +17,8 @@ const PatchParams = z.object({
|
||||
})
|
||||
|
||||
export const PatchTool = Tool.define("patch", {
|
||||
description: "Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.",
|
||||
description:
|
||||
"Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.",
|
||||
parameters: PatchParams,
|
||||
async execute(params, ctx) {
|
||||
if (!params.patchText) {
|
||||
@@ -46,12 +47,12 @@ export const PatchTool = Tool.define("patch", {
|
||||
type: "add" | "update" | "delete" | "move"
|
||||
movePath?: string
|
||||
}> = []
|
||||
|
||||
|
||||
let totalDiff = ""
|
||||
|
||||
for (const hunk of hunks) {
|
||||
const filePath = path.resolve(Instance.directory, hunk.path)
|
||||
|
||||
|
||||
if (!Filesystem.contains(Instance.directory, filePath)) {
|
||||
throw new Error(`File ${filePath} is not in the current working directory`)
|
||||
}
|
||||
@@ -62,30 +63,30 @@ export const PatchTool = Tool.define("patch", {
|
||||
const oldContent = ""
|
||||
const newContent = hunk.contents
|
||||
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
||||
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
newContent,
|
||||
type: "add",
|
||||
})
|
||||
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
case "update":
|
||||
// Check if file exists for update
|
||||
const stats = await fs.stat(filePath).catch(() => null)
|
||||
if (!stats || stats.isDirectory()) {
|
||||
throw new Error(`File not found or is directory: ${filePath}`)
|
||||
}
|
||||
|
||||
|
||||
// Read file and update time tracking (like edit tool does)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
const oldContent = await fs.readFile(filePath, "utf-8")
|
||||
let newContent = oldContent
|
||||
|
||||
|
||||
// Apply the update chunks to get new content
|
||||
try {
|
||||
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
|
||||
@@ -93,9 +94,9 @@ export const PatchTool = Tool.define("patch", {
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to apply update to ${filePath}: ${error}`)
|
||||
}
|
||||
|
||||
|
||||
const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
|
||||
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent,
|
||||
@@ -103,23 +104,23 @@ export const PatchTool = Tool.define("patch", {
|
||||
type: hunk.move_path ? "move" : "update",
|
||||
movePath: hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined,
|
||||
})
|
||||
|
||||
|
||||
totalDiff += diff + "\n"
|
||||
break
|
||||
|
||||
|
||||
case "delete":
|
||||
// Check if file exists for deletion
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
const contentToDelete = await fs.readFile(filePath, "utf-8")
|
||||
const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "")
|
||||
|
||||
|
||||
fileChanges.push({
|
||||
filePath,
|
||||
oldContent: contentToDelete,
|
||||
newContent: "",
|
||||
type: "delete",
|
||||
})
|
||||
|
||||
|
||||
totalDiff += deleteDiff + "\n"
|
||||
break
|
||||
}
|
||||
@@ -141,7 +142,7 @@ export const PatchTool = Tool.define("patch", {
|
||||
|
||||
// Apply the changes
|
||||
const changedFiles: string[] = []
|
||||
|
||||
|
||||
for (const change of fileChanges) {
|
||||
switch (change.type) {
|
||||
case "add":
|
||||
@@ -153,12 +154,12 @@ export const PatchTool = Tool.define("patch", {
|
||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
|
||||
|
||||
case "update":
|
||||
await fs.writeFile(change.filePath, change.newContent, "utf-8")
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
|
||||
|
||||
case "move":
|
||||
if (change.movePath) {
|
||||
// Create parent directories for destination
|
||||
@@ -173,13 +174,13 @@ export const PatchTool = Tool.define("patch", {
|
||||
changedFiles.push(change.movePath)
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
case "delete":
|
||||
await fs.unlink(change.filePath)
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
// Update file time tracking
|
||||
FileTime.read(ctx.sessionID, change.filePath)
|
||||
if (change.movePath) {
|
||||
@@ -193,7 +194,7 @@ export const PatchTool = Tool.define("patch", {
|
||||
}
|
||||
|
||||
// Generate output summary
|
||||
const relativePaths = changedFiles.map(filePath => path.relative(Instance.worktree, filePath))
|
||||
const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath))
|
||||
const summary = `${fileChanges.length} files changed`
|
||||
|
||||
return {
|
||||
@@ -201,7 +202,7 @@ export const PatchTool = Tool.define("patch", {
|
||||
metadata: {
|
||||
diff: totalDiff,
|
||||
},
|
||||
output: `Patch applied successfully. ${summary}:\n${relativePaths.map(p => ` ${p}`).join("\n")}`,
|
||||
output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`,
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Instance } from "../project/instance"
|
||||
import { Config } from "../config/config"
|
||||
import path from "path"
|
||||
import { type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Plugin } from "../plugin"
|
||||
|
||||
export namespace ToolRegistry {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./task.txt"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Session } from "../session"
|
||||
import { Bus } from "../bus"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION_WRITE from "./todowrite.txt"
|
||||
import { Todo } from "../session/todo"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
|
||||
export namespace Tool {
|
||||
@@ -32,6 +32,9 @@ export namespace Tool {
|
||||
}>
|
||||
}
|
||||
|
||||
export type InferParameters<T extends Info> = T extends Info<infer P> ? z.infer<P> : never
|
||||
export type InferMetadata<T extends Info> = T extends Info<any, infer M> ? M : never
|
||||
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata>(
|
||||
id: string,
|
||||
init: Info<Parameters, Result>["init"] | Awaited<ReturnType<Info<Parameters, Result>["init"]>>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import TurndownService from "turndown"
|
||||
import DESCRIPTION from "./webfetch.txt"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
|
||||
export abstract class NamedError extends Error {
|
||||
abstract schema(): z.core.$ZodType
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Global } from "../global"
|
||||
import z from "zod/v4"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Log {
|
||||
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Session } from "../../src/session"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
Log.init({ print: false })
|
||||
|
||||
describe("session.started event", () => {
|
||||
test("should emit session.started event when session is created", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
let eventReceived = false
|
||||
let receivedInfo: Session.Info | undefined
|
||||
|
||||
const unsub = Bus.subscribe(Session.Event.Created, (event) => {
|
||||
eventReceived = true
|
||||
receivedInfo = event.properties.info as Session.Info
|
||||
})
|
||||
|
||||
const session = await Session.create({})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
unsub()
|
||||
|
||||
expect(eventReceived).toBe(true)
|
||||
expect(receivedInfo).toBeDefined()
|
||||
expect(receivedInfo?.id).toBe(session.id)
|
||||
expect(receivedInfo?.projectID).toBe(session.projectID)
|
||||
expect(receivedInfo?.directory).toBe(session.directory)
|
||||
expect(receivedInfo?.title).toBe(session.title)
|
||||
|
||||
await Session.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("session.started event should be emitted before session.updated", async () => {
|
||||
await Instance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const events: string[] = []
|
||||
|
||||
const unsubStarted = Bus.subscribe(Session.Event.Created, () => {
|
||||
events.push("started")
|
||||
})
|
||||
|
||||
const unsubUpdated = Bus.subscribe(Session.Event.Updated, () => {
|
||||
events.push("updated")
|
||||
})
|
||||
|
||||
const session = await Session.create({})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
unsubStarted()
|
||||
unsubUpdated()
|
||||
|
||||
expect(events).toContain("started")
|
||||
expect(events).toContain("updated")
|
||||
expect(events.indexOf("started")).toBeLessThan(events.indexOf("updated"))
|
||||
|
||||
await Session.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "0.15.18",
|
||||
"version": "0.15.20",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -24,4 +24,4 @@
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod/v4"
|
||||
import { z } from "zod"
|
||||
|
||||
export type ToolContext = {
|
||||
sessionID: string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user