diff --git a/.github/workflows/nix-desktop.yml b/.github/workflows/nix-desktop.yml index 01cfaed78b4..3d7c4803133 100644 --- a/.github/workflows/nix-desktop.yml +++ b/.github/workflows/nix-desktop.yml @@ -9,6 +9,7 @@ on: - "nix/**" - "packages/app/**" - "packages/desktop/**" + - ".github/workflows/nix-desktop.yml" pull_request: paths: - "flake.nix" @@ -16,6 +17,7 @@ on: - "nix/**" - "packages/app/**" - "packages/desktop/**" + - ".github/workflows/nix-desktop.yml" workflow_dispatch: jobs: @@ -26,7 +28,7 @@ jobs: os: - blacksmith-4vcpu-ubuntu-2404 - blacksmith-4vcpu-ubuntu-2404-arm - - macos-15 + - macos-15-intel - macos-latest runs-on: ${{ matrix.os }} timeout-minutes: 60 diff --git a/.gitignore b/.gitignore index 41e6625a036..c8a8665afdd 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ a.out target .scripts docker/workspace +.direnv/ # Local dev files opencode-dev diff --git a/STATS.md b/STATS.md index e09c57e8f41..9a665612b14 100644 --- a/STATS.md +++ b/STATS.md @@ -202,3 +202,4 @@ | 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) | | 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) | | 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) | +| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) | diff --git a/bun.lock b/bun.lock index 1b6e681de83..ab00c1751b8 100644 --- a/bun.lock +++ b/bun.lock @@ -426,6 +426,7 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", + "strip-ansi": "7.1.2", "virtua": "catalog:", }, "devDependencies": { diff --git a/flake.lock b/flake.lock index 58bdca6bf6a..2bfad510e7b 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1768395095, - "narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=", + "lastModified": 1768456270, + "narHash": "sha256-NgaL2CCiUR6nsqUIY4yxkzz07iQUlUCany44CFv+OxY=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5", + "rev": "f4606b01b39e09065df37905a2133905246db9ed", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 4219a7e8e10..32614640ad3 100644 --- a/flake.nix +++ b/flake.nix @@ -7,6 +7,7 @@ outputs = { + self, nixpkgs, ... }: @@ -107,33 +108,10 @@ }; in { - default = opencodePkg; + default = self.packages.${system}.opencode; + opencode = opencodePkg; desktop = desktopPkg; } ); - - apps = forEachSystem ( - system: - let - pkgs = pkgsFor system; - in - { - opencode-dev = { - type = "app"; - meta = { - description = "Nix devshell shell for OpenCode"; - runtimeInputs = [ pkgs.bun ]; - }; - program = "${ - pkgs.writeShellApplication { - name = "opencode-dev"; - text = '' - exec bun run dev "$@" - ''; - } - }/bin/opencode-dev"; - }; - } - ); }; } diff --git a/nix/desktop.nix b/nix/desktop.nix index 4b659413aaa..9fb73b56316 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -15,6 +15,8 @@ cargo, rustc, makeBinaryWrapper, + copyDesktopItems, + makeDesktopItem, nodejs, jq, }: @@ -57,12 +59,28 @@ rustPlatform.buildRustPackage rec { pkg-config bun makeBinaryWrapper + copyDesktopItems cargo rustc nodejs jq ]; + # based on packages/desktop/src-tauri/release/appstream.metainfo.xml + desktopItems = lib.optionals stdenv.isLinux [ + (makeDesktopItem { + name = "ai.opencode.opencode"; + desktopName = "OpenCode"; + comment = "Open source AI coding agent"; + exec = "opencode-desktop"; + icon = "opencode"; + terminal = false; + type = "Application"; + categories = [ "Development" "IDE" ]; + startupWMClass = "opencode"; + }) + ]; + buildInputs = [ openssl ] @@ -121,6 +139,10 @@ rustPlatform.buildRustPackage rec { # It looks for them in the location specified in tauri.conf.json. postInstall = lib.optionalString stdenv.isLinux '' + # Install icon + mkdir -p $out/share/icons/hicolor/128x128/apps + cp ../../../packages/desktop/src-tauri/icons/prod/128x128.png $out/share/icons/hicolor/128x128/apps/opencode.png + # Wrap the binary to ensure it finds the libraries wrapProgram $out/bin/opencode-desktop \ --prefix LD_LIBRARY_PATH : ${ diff --git a/nix/hashes.json b/nix/hashes.json index 255e44fe366..16a1c1f398b 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-07XxcHLuToM4QfWVyaPLACxjPZ93ZM7gtpX2o08Lp18=", - "aarch64-linux": "sha256-0Im52dLeZ0ZtaPJr/U4m7+IRtOfziHNJI/Bu/V6cPho=", - "aarch64-darwin": "sha256-U2UvE70nM0OI0VhIku8qnX+ptPbA+Q/y1BGXbFMcyt4=", - "x86_64-darwin": "sha256-CpZFHBMPJSib2Vqs6oC8HQjQtviPUMa/qezHAe22N/A=" + "x86_64-linux": "sha256-4zchRpxzvHnPMcwumgL9yaX0deIXS5IGPp131eYsSvg=", + "aarch64-linux": "sha256-3/BSRsl5pI0Iz3qAFZxIkOehFLZ2Ox9UsbdDHYzqlVg=", + "aarch64-darwin": "sha256-86d/G1q6xiHSSlm+/irXoKLb/yLQbV348uuSrBV70+Q=", + "x86_64-darwin": "sha256-WYaP44PWRGtoG1DIuUJUH4DvuaCuFhlJZ9fPzGsiIfE=" } } diff --git a/nix/scripts/update-hashes.sh b/nix/scripts/update-hashes.sh deleted file mode 100755 index 1e294fe4fb4..00000000000 --- a/nix/scripts/update-hashes.sh +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" -SYSTEM=${SYSTEM:-x86_64-linux} -DEFAULT_HASH_FILE=${MODULES_HASH_FILE:-nix/hashes.json} -HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE} - -if [ ! -f "$HASH_FILE" ]; then - cat >"$HASH_FILE" </dev/null 2>&1; then - if ! git ls-files --error-unmatch "$HASH_FILE" >/dev/null 2>&1; then - git add -N "$HASH_FILE" >/dev/null 2>&1 || true - fi -fi - -export DUMMY -export NIX_KEEP_OUTPUTS=1 -export NIX_KEEP_DERIVATIONS=1 - -cleanup() { - rm -f "${JSON_OUTPUT:-}" "${BUILD_LOG:-}" "${TMP_EXPR:-}" -} - -trap cleanup EXIT - -write_node_modules_hash() { - local value="$1" - local system="${2:-$SYSTEM}" - local temp - temp=$(mktemp) - - if jq -e '.nodeModules | type == "object"' "$HASH_FILE" >/dev/null 2>&1; then - jq --arg system "$system" --arg value "$value" '.nodeModules[$system] = $value' "$HASH_FILE" >"$temp" - else - jq --arg system "$system" --arg value "$value" '.nodeModules = {($system): $value}' "$HASH_FILE" >"$temp" - fi - - mv "$temp" "$HASH_FILE" -} - -TARGET="packages.${SYSTEM}.default" -MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules" -CORRECT_HASH="" - -DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")" - -echo "Setting dummy node_modules outputHash for ${SYSTEM}..." -write_node_modules_hash "$DUMMY" - -BUILD_LOG=$(mktemp) -JSON_OUTPUT=$(mktemp) - -echo "Building node_modules for ${SYSTEM} to discover correct outputHash..." -echo "Attempting to realize derivation: ${DRV_PATH}" -REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true) - -BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true) -if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then - echo "Realized node_modules output: $BUILD_PATH" - CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true) -fi - -if [ -z "$CORRECT_HASH" ]; then - CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)" - - if [ -z "$CORRECT_HASH" ]; then - CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)" - fi - - if [ -z "$CORRECT_HASH" ]; then - echo "Searching for kept failed build directory..." - KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1) - - if [ -z "$KEPT_DIR" ]; then - KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1) - fi - - if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then - echo "Found kept build directory: $KEPT_DIR" - if [ -d "$KEPT_DIR/build" ]; then - HASH_PATH="$KEPT_DIR/build" - else - HASH_PATH="$KEPT_DIR" - fi - - echo "Attempting to hash: $HASH_PATH" - ls -la "$HASH_PATH" || true - - if [ -d "$HASH_PATH/node_modules" ]; then - CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true) - echo "Computed hash from kept build: $CORRECT_HASH" - fi - fi - fi -fi - -if [ -z "$CORRECT_HASH" ]; then - echo "Failed to determine correct node_modules hash for ${SYSTEM}." - echo "Build log:" - cat "$BUILD_LOG" - exit 1 -fi - -write_node_modules_hash "$CORRECT_HASH" - -jq -e --arg system "$SYSTEM" --arg hash "$CORRECT_HASH" '.nodeModules[$system] == $hash' "$HASH_FILE" >/dev/null - -echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH" - -rm -f "$BUILD_LOG" -unset BUILD_LOG diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 3b80c2687f1..432e531e192 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -1,6 +1,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Keybind } from "@opencode-ai/ui/keybind" import { List } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useParams } from "@solidjs/router" @@ -133,14 +134,14 @@ export function DialogSelectFile() { }) return ( - + item.id} filterKeys={["title", "description", "category"]} - groupBy={(item) => (grouped() ? item.category : "")} + groupBy={(item) => item.category} onMove={handleMove} onSelect={handleSelect} > @@ -161,7 +162,7 @@ export function DialogSelectFile() { } > -
+
{item.title} @@ -169,7 +170,7 @@ export function DialogSelectFile() {
- {formatKeybind(item.keybind ?? "")} + {formatKeybind(item.keybind ?? "")}
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 3c640d8e9fa..a93ffc02454 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -104,7 +104,15 @@ export function formatKeybind(config: string): string { if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") if (kb.key) { - const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1) + const arrows: Record = { + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + } + const displayKey = + arrows[kb.key.toLowerCase()] ?? + (kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)) parts.push(displayKey) } diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 7b61d4702a2..1c000a62d9b 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -394,6 +394,8 @@ function createGlobalSync() { }), ) } + if (event.properties.info.parentID) break + setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } if (result.found) { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5d1fc747bcd..c2b95a5f502 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -470,7 +470,6 @@ export default function Page() { { id: "session.new", title: "New session", - description: "Create a new session", category: "Session", keybind: "mod+shift+s", slash: "new", @@ -488,7 +487,7 @@ export default function Page() { { id: "terminal.toggle", title: "Toggle terminal", - description: "Show or hide the terminal", + description: "", category: "View", keybind: "ctrl+`", slash: "terminal", @@ -497,7 +496,7 @@ export default function Page() { { id: "review.toggle", title: "Toggle review", - description: "Show or hide the review panel", + description: "", category: "View", keybind: "mod+shift+r", onSelect: () => view().reviewPanel.toggle(), diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index 3422d9dd65d..4c3430193e4 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -183,7 +183,12 @@ export async function POST(input: APIEvent) { .set({ customerID, subscriptionID, - subscriptionCouponID: couponID, + subscription: { + status: "subscribed", + coupon: couponID, + seats: 1, + plan: "200", + }, paymentMethodID: paymentMethod.id, paymentMethodLast4: paymentMethod.card?.last4 ?? null, paymentMethodType: paymentMethod.type, @@ -408,7 +413,7 @@ export async function POST(input: APIEvent) { await Database.transaction(async (tx) => { await tx .update(BillingTable) - .set({ subscriptionID: null, subscriptionCouponID: null }) + .set({ subscriptionID: null, subscription: null }) .where(eq(BillingTable.workspaceID, workspaceID)) await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID)) diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index 2546ad3ef15..a5f92a29acf 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -65,7 +65,6 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => buffer = newBuffer const messages = [] - while (buffer.length >= 4) { // first 4 bytes are the total length (big-endian) const totalLength = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(0, false) @@ -121,7 +120,9 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => const parsedDataResult = JSON.parse(data) delete parsedDataResult.p - const bytes = atob(parsedDataResult.bytes) + const binary = atob(parsedDataResult.bytes) + const uint8 = Uint8Array.from(binary, (c) => c.charCodeAt(0)) + const bytes = decoder.decode(uint8) const eventName = JSON.parse(bytes).type messages.push([`event: ${eventName}`, "\n", `data: ${bytes}`, "\n\n"].join("")) } diff --git a/packages/console/core/migrations/0053_gigantic_hardball.sql b/packages/console/core/migrations/0053_gigantic_hardball.sql new file mode 100644 index 00000000000..72d43135f44 --- /dev/null +++ b/packages/console/core/migrations/0053_gigantic_hardball.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` ADD `subscription` json; \ No newline at end of file diff --git a/packages/console/core/migrations/0054_numerous_annihilus.sql b/packages/console/core/migrations/0054_numerous_annihilus.sql new file mode 100644 index 00000000000..299847db64f --- /dev/null +++ b/packages/console/core/migrations/0054_numerous_annihilus.sql @@ -0,0 +1 @@ +ALTER TABLE `billing` DROP COLUMN `subscription_coupon_id`; \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0053_snapshot.json b/packages/console/core/migrations/meta/0053_snapshot.json new file mode 100644 index 00000000000..75a2cb7c929 --- /dev/null +++ b/packages/console/core/migrations/meta/0053_snapshot.json @@ -0,0 +1,1242 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "32a0c40b-a269-4ad1-a5a0-52b1f18932aa", + "prevId": "00774acd-a1e5-49c0-b296-cacc9506a566", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": ["provider", "subject"], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": ["account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": ["time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription": { + "name": "subscription", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_coupon_id": { + "name": "subscription_coupon_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_plan": { + "name": "subscription_plan", + "type": "enum('20','100','200')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_subscription_booked": { + "name": "time_subscription_booked", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": ["customer_id"], + "isUnique": true + }, + "global_subscription_id": { + "name": "global_subscription_id", + "columns": ["subscription_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rolling_usage": { + "name": "rolling_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fixed_usage": { + "name": "fixed_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_rolling_updated": { + "name": "time_rolling_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_fixed_updated": { + "name": "time_fixed_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_user_id": { + "name": "workspace_user_id", + "columns": ["workspace_id", "user_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscription_workspace_id_id_pk": { + "name": "subscription_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "usage_time_created": { + "name": "usage_time_created", + "columns": ["workspace_id", "time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip_rate_limit": { + "name": "ip_rate_limit", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_rate_limit_ip_interval_pk": { + "name": "ip_rate_limit_ip_interval_pk", + "columns": ["ip", "interval"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": ["ip"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": ["workspace_id", "model"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": ["workspace_id", "provider"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": ["workspace_id", "account_id"], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": ["workspace_id", "email"], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": ["account_id"], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": ["email"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/console/core/migrations/meta/0054_snapshot.json b/packages/console/core/migrations/meta/0054_snapshot.json new file mode 100644 index 00000000000..a1e3851d857 --- /dev/null +++ b/packages/console/core/migrations/meta/0054_snapshot.json @@ -0,0 +1,1235 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "a0ade64b-b735-4a70-8d39-ebd84bc9e924", + "prevId": "32a0c40b-a269-4ad1-a5a0-52b1f18932aa", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id_pk": { + "name": "account_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "auth": { + "name": "auth", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "enum('email','github','google')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider": { + "name": "provider", + "columns": ["provider", "subject"], + "isUnique": true + }, + "account_id": { + "name": "account_id", + "columns": ["account_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "auth_id_pk": { + "name": "auth_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "benchmark": { + "name": "benchmark", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent": { + "name": "agent", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_created": { + "name": "time_created", + "columns": ["time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "benchmark_id_pk": { + "name": "benchmark_id_pk", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "billing": { + "name": "billing", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method_last4": { + "name": "payment_method_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload": { + "name": "reload", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_trigger": { + "name": "reload_trigger", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_amount": { + "name": "reload_amount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reload_error": { + "name": "reload_error", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_error": { + "name": "time_reload_error", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_reload_locked_till": { + "name": "time_reload_locked_till", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription": { + "name": "subscription", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(28)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_plan": { + "name": "subscription_plan", + "type": "enum('20','100','200')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_subscription_booked": { + "name": "time_subscription_booked", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_customer_id": { + "name": "global_customer_id", + "columns": ["customer_id"], + "isUnique": true + }, + "global_subscription_id": { + "name": "global_subscription_id", + "columns": ["subscription_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "billing_workspace_id_id_pk": { + "name": "billing_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "payment": { + "name": "payment", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_id": { + "name": "payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_refunded": { + "name": "time_refunded", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "payment_workspace_id_id_pk": { + "name": "payment_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "subscription": { + "name": "subscription", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rolling_usage": { + "name": "rolling_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fixed_usage": { + "name": "fixed_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_rolling_updated": { + "name": "time_rolling_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_fixed_updated": { + "name": "time_fixed_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_user_id": { + "name": "workspace_user_id", + "columns": ["workspace_id", "user_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "subscription_workspace_id_id_pk": { + "name": "subscription_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "usage": { + "name": "usage", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reasoning_tokens": { + "name": "reasoning_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_5m_tokens": { + "name": "cache_write_5m_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_1h_tokens": { + "name": "cache_write_1h_tokens", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_id": { + "name": "key_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enrichment": { + "name": "enrichment", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "usage_time_created": { + "name": "usage_time_created", + "columns": ["workspace_id", "time_created"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_workspace_id_id_pk": { + "name": "usage_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip_rate_limit": { + "name": "ip_rate_limit", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interval": { + "name": "interval", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_rate_limit_ip_interval_pk": { + "name": "ip_rate_limit_ip_interval_pk", + "columns": ["ip", "interval"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ip": { + "name": "ip", + "columns": { + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "usage": { + "name": "usage", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ip_ip_pk": { + "name": "ip_ip_pk", + "columns": ["ip"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "key": { + "name": "key", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_used": { + "name": "time_used", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "global_key": { + "name": "global_key", + "columns": ["key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "key_workspace_id_id_pk": { + "name": "key_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "model": { + "name": "model", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_workspace_model": { + "name": "model_workspace_model", + "columns": ["workspace_id", "model"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "model_workspace_id_id_pk": { + "name": "model_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "provider": { + "name": "provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials": { + "name": "credentials", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_provider": { + "name": "workspace_provider", + "columns": ["workspace_id", "provider"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "provider_workspace_id_id_pk": { + "name": "provider_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_seen": { + "name": "time_seen", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','member')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthly_usage": { + "name": "monthly_usage", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "time_monthly_usage_updated": { + "name": "time_monthly_usage_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_account_id": { + "name": "user_account_id", + "columns": ["workspace_id", "account_id"], + "isUnique": true + }, + "user_email": { + "name": "user_email", + "columns": ["workspace_id", "email"], + "isUnique": true + }, + "global_account_id": { + "name": "global_account_id", + "columns": ["account_id"], + "isUnique": false + }, + "global_email": { + "name": "global_email", + "columns": ["email"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_workspace_id_id_pk": { + "name": "user_workspace_id_id_pk", + "columns": ["workspace_id", "id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_created": { + "name": "time_created", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "slug": { + "name": "slug", + "columns": ["slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workspace_id": { + "name": "workspace_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json index cdf4f63906d..dd0957e51ca 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -372,6 +372,20 @@ "when": 1768343920467, "tag": "0052_aromatic_agent_zero", "breakpoints": true + }, + { + "idx": 53, + "version": "5", + "when": 1768599366758, + "tag": "0053_gigantic_hardball", + "breakpoints": true + }, + { + "idx": 54, + "version": "5", + "when": 1768603665356, + "tag": "0054_numerous_annihilus", + "breakpoints": true } ] } diff --git a/packages/console/core/script/black-gift.ts b/packages/console/core/script/black-gift.ts new file mode 100644 index 00000000000..3fbf210ab5c --- /dev/null +++ b/packages/console/core/script/black-gift.ts @@ -0,0 +1,112 @@ +import { Billing } from "../src/billing.js" +import { and, Database, eq, isNull, sql } from "../src/drizzle/index.js" +import { UserTable } from "../src/schema/user.sql.js" +import { BillingTable, PaymentTable, SubscriptionTable } from "../src/schema/billing.sql.js" +import { Identifier } from "../src/identifier.js" +import { centsToMicroCents } from "../src/util/price.js" +import { AuthTable } from "../src/schema/auth.sql.js" + +const plan = "200" +const workspaceID = process.argv[2] +const seats = parseInt(process.argv[3]) + +console.log(`Gifting ${seats} seats of Black to workspace ${workspaceID}`) + +if (!workspaceID || !seats) throw new Error("Usage: bun foo.ts ") + +// Get workspace user +const users = await Database.use((tx) => + tx + .select({ + id: UserTable.id, + role: UserTable.role, + email: AuthTable.subject, + }) + .from(UserTable) + .leftJoin(AuthTable, and(eq(AuthTable.accountID, UserTable.accountID), eq(AuthTable.provider, "email"))) + .where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))), +) +if (users.length === 0) throw new Error(`Error: No users found in workspace ${workspaceID}`) +if (users.length !== seats) + throw new Error(`Error: Workspace ${workspaceID} has ${users.length} users, expected ${seats}`) +const adminUser = users.find((user) => user.role === "admin") +if (!adminUser) throw new Error(`Error: No admin user found in workspace ${workspaceID}`) +if (!adminUser.email) throw new Error(`Error: Admin user ${adminUser.id} has no email`) + +// Get Billing +const billing = await Database.use((tx) => + tx + .select({ + customerID: BillingTable.customerID, + subscriptionID: BillingTable.subscriptionID, + }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, workspaceID)) + .then((rows) => rows[0]), +) +if (!billing) throw new Error(`Error: Workspace ${workspaceID} has no billing record`) +if (billing.subscriptionID) throw new Error(`Error: Workspace ${workspaceID} already has a subscription`) + +// Look up the Stripe customer by email +const customerID = + billing.customerID ?? + (await (() => + Billing.stripe() + .customers.create({ + email: adminUser.email, + metadata: { + workspaceID, + }, + }) + .then((customer) => customer.id))()) +console.log(`Customer ID: ${customerID}`) + +const couponID = "JAIr0Pe1" +const subscription = await Billing.stripe().subscriptions.create({ + customer: customerID!, + items: [ + { + price: `price_1SmfyI2StuRr0lbXovxJNeZn`, + discounts: [{ coupon: couponID }], + quantity: 2, + }, + ], +}) +console.log(`Subscription ID: ${subscription.id}`) + +await Database.transaction(async (tx) => { + // Set customer id, subscription id, and payment method on workspace billing + await tx + .update(BillingTable) + .set({ + customerID, + subscriptionID: subscription.id, + subscription: { status: "subscribed", coupon: couponID, seats, plan }, + }) + .where(eq(BillingTable.workspaceID, workspaceID)) + + // Create a row in subscription table + for (const user of users) { + await tx.insert(SubscriptionTable).values({ + workspaceID, + id: Identifier.create("subscription"), + userID: user.id, + }) + } + // + // // Create a row in payments table + // await tx.insert(PaymentTable).values({ + // workspaceID, + // id: Identifier.create("payment"), + // amount: centsToMicroCents(amountInCents), + // customerID, + // invoiceID, + // paymentID, + // enrichment: { + // type: "subscription", + // couponID, + // }, + // }) +}) + +console.log(`done`) diff --git a/packages/console/core/script/onboard-zen-black.ts b/packages/console/core/script/black-onboard.ts similarity index 95% rename from packages/console/core/script/onboard-zen-black.ts rename to packages/console/core/script/black-onboard.ts index 3ee8809739d..77e5b779e35 100644 --- a/packages/console/core/script/onboard-zen-black.ts +++ b/packages/console/core/script/black-onboard.ts @@ -12,7 +12,7 @@ const email = process.argv[3] console.log(`Onboarding workspace ${workspaceID} for email ${email}`) if (!workspaceID || !email) { - console.error("Usage: bun onboard-zen-black.ts ") + console.error("Usage: bun foo.ts ") process.exit(1) } @@ -50,7 +50,7 @@ const existingSubscription = await Database.use((tx) => tx .select({ workspaceID: BillingTable.workspaceID }) .from(BillingTable) - .where(eq(BillingTable.subscriptionID, subscriptionID)) + .where(sql`JSON_EXTRACT(${BillingTable.subscription}, '$.id') = ${subscriptionID}`) .then((rows) => rows[0]), ) if (existingSubscription) { @@ -128,10 +128,15 @@ await Database.transaction(async (tx) => { .set({ customerID, subscriptionID, - subscriptionCouponID: couponID, paymentMethodID, paymentMethodLast4, paymentMethodType, + subscription: { + status: "subscribed", + coupon: couponID, + seats: 1, + plan: "200", + }, }) .where(eq(BillingTable.workspaceID, workspaceID)) diff --git a/packages/console/core/script/black-transfer.ts b/packages/console/core/script/black-transfer.ts index a7947fe7223..e962ba5d361 100644 --- a/packages/console/core/script/black-transfer.ts +++ b/packages/console/core/script/black-transfer.ts @@ -18,7 +18,7 @@ const fromBilling = await Database.use((tx) => .select({ customerID: BillingTable.customerID, subscriptionID: BillingTable.subscriptionID, - subscriptionCouponID: BillingTable.subscriptionCouponID, + subscription: BillingTable.subscription, paymentMethodID: BillingTable.paymentMethodID, paymentMethodType: BillingTable.paymentMethodType, paymentMethodLast4: BillingTable.paymentMethodLast4, @@ -119,7 +119,7 @@ await Database.transaction(async (tx) => { .set({ customerID: fromPrevPayment.customerID, subscriptionID: null, - subscriptionCouponID: null, + subscription: null, paymentMethodID: fromPrevPaymentMethods.data[0].id, paymentMethodLast4: fromPrevPaymentMethods.data[0].card?.last4 ?? null, paymentMethodType: fromPrevPaymentMethods.data[0].type, @@ -131,7 +131,7 @@ await Database.transaction(async (tx) => { .set({ customerID: fromBilling.customerID, subscriptionID: fromBilling.subscriptionID, - subscriptionCouponID: fromBilling.subscriptionCouponID, + subscription: fromBilling.subscription, paymentMethodID: fromBilling.paymentMethodID, paymentMethodLast4: fromBilling.paymentMethodLast4, paymentMethodType: fromBilling.paymentMethodType, diff --git a/packages/console/core/script/lookup-user.ts b/packages/console/core/script/lookup-user.ts index b3a104457ff..3dc5e7a968c 100644 --- a/packages/console/core/script/lookup-user.ts +++ b/packages/console/core/script/lookup-user.ts @@ -55,8 +55,9 @@ if (identifier.startsWith("wrk_")) { ), ) - // Get all payments for these workspaces - await Promise.all(users.map((u: { workspaceID: string }) => printWorkspace(u.workspaceID))) + for (const user of users) { + await printWorkspace(user.workspaceID) + } } async function printWorkspace(workspaceID: string) { @@ -114,11 +115,11 @@ async function printWorkspace(workspaceID: string) { balance: BillingTable.balance, customerID: BillingTable.customerID, reload: BillingTable.reload, + subscriptionID: BillingTable.subscriptionID, subscription: { - id: BillingTable.subscriptionID, - couponID: BillingTable.subscriptionCouponID, plan: BillingTable.subscriptionPlan, booked: BillingTable.timeSubscriptionBooked, + enrichment: BillingTable.subscription, }, }) .from(BillingTable) @@ -128,8 +129,13 @@ async function printWorkspace(workspaceID: string) { rows.map((row) => ({ ...row, balance: `$${(row.balance / 100000000).toFixed(2)}`, - subscription: row.subscription.id - ? `Subscribed ${row.subscription.couponID ? `(coupon: ${row.subscription.couponID}) ` : ""}` + subscription: row.subscriptionID + ? [ + `Black ${row.subscription.enrichment!.plan}`, + row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "", + row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "", + `(ref: ${row.subscriptionID})`, + ].join(" ") : row.subscription.booked ? `Waitlist ${row.subscription.plan} plan` : undefined, diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index f1300f8498b..9f05919f240 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -21,8 +21,13 @@ export const BillingTable = mysqlTable( reloadError: varchar("reload_error", { length: 255 }), timeReloadError: utc("time_reload_error"), timeReloadLockedTill: utc("time_reload_locked_till"), + subscription: json("subscription").$type<{ + status: "subscribed" + coupon?: string + seats: number + plan: "20" | "100" | "200" + }>(), subscriptionID: varchar("subscription_id", { length: 28 }), - subscriptionCouponID: varchar("subscription_coupon_id", { length: 28 }), subscriptionPlan: mysqlEnum("subscription_plan", ["20", "100", "200"] as const), timeSubscriptionBooked: utc("time_subscription_booked"), }, diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 7a46ba8cde0..8398f457766 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -26,6 +26,18 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) { ) } +const isWindows = ostype() === "windows" +if (isWindows) { + const originalGetComputedStyle = window.getComputedStyle + window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => { + if (!(elt instanceof Element)) { + // WebView2 can call into Floating UI with non-elements; fall back to a safe element. + return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined) + } + return originalGetComputedStyle(elt, pseudoElt ?? undefined) + }) as typeof window.getComputedStyle +} + let update: Update | null = null const createPlatform = (password: Accessor): Platform => ({ diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index f8792393c60..de8904c0928 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -351,8 +351,8 @@ export namespace ACP { log.info("initialize", { protocolVersion: params.protocolVersion }) const authMethod: AuthMethod = { - description: "Run `opencode auth login` in the terminal", - name: "Login with opencode", + description: "Run `shuvcode auth login` in the terminal", + name: "Login with shuvcode", id: "opencode-login", } @@ -360,9 +360,9 @@ export namespace ACP { if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { authMethod._meta = { "terminal-auth": { - command: "opencode", + command: "shuvcode", args: ["auth", "login"], - label: "OpenCode Login", + label: "Shuvcode Login", }, } } diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index fedad92856f..7b99831fb05 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -6,7 +6,6 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" -import { McpOAuthCallback } from "../../mcp/oauth-callback" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "../../config/config" import { Instance } from "../../project/instance" @@ -85,7 +84,7 @@ export const McpListCommand = cmd({ if (servers.length === 0) { prompts.log.warn("No MCP servers configured") - prompts.outro("Add servers with: opencode mcp add") + prompts.outro("Add servers with: shuvcode mcp add") return } @@ -683,10 +682,6 @@ export const McpDebugCommand = cmd({ // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - - // Start callback server - await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) - const authProvider = new McpOAuthProvider( serverName, serverConfig.url, @@ -694,7 +689,6 @@ export const McpDebugCommand = cmd({ clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async () => {}, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx new file mode 100644 index 00000000000..078780dd4e4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-usage.tsx @@ -0,0 +1,170 @@ +import { TextAttributes } from "@opentui/core" +import { useTheme } from "../context/theme" +import { For, Show } from "solid-js" + +type Theme = ReturnType["theme"] + +type UsageWindow = { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null +} + +export type UsageEntry = { + provider: string + displayName: string + snapshot: { + primary: UsageWindow | null + secondary: UsageWindow | null + credits: { + hasCredits: boolean + unlimited: boolean + balance: string | null + } | null + planType: string | null + updatedAt: number + } +} + +export function DialogUsage(props: { entries: UsageEntry[] }) { + const { theme } = useTheme() + + return ( + + + + Usage + + esc + + 0} fallback={No usage data available.}> + + {(entry, index) => { + const mergeReset = entry.provider === "copilot" + const resetAt = entry.snapshot.primary?.resetsAt ?? entry.snapshot.secondary?.resetsAt ?? null + return ( + + + + {entry.displayName} Usage ({formatPlanType(entry.snapshot.planType)} Plan) + + {"─".repeat(Math.max(24, entry.displayName.length + 20))} + + + {(window) => ( + + {renderWindow(getWindowLabel(entry.provider, "primary"), window(), theme, !mergeReset)} + + )} + + + {(window) => ( + + {renderWindow(getWindowLabel(entry.provider, "secondary"), window(), theme, !mergeReset)} + + )} + + + Resets {formatResetTime(resetAt!)} + + + {(credits) => {formatCreditsLabel(entry.provider, credits())}} + + + ) + }} + + + + ) +} + +function getWindowLabel(provider: string, windowType: "primary" | "secondary"): string { + if (provider === "copilot") { + return windowType === "primary" ? "Usage" : "Completions" + } + return windowType === "primary" ? "Hourly" : "Weekly" +} + +function renderWindow(label: string, window: UsageWindow, theme: Theme, showReset = true) { + const usedPercent = clampPercent(window.usedPercent) + const bar = renderProgressBar(usedPercent) + const windowLabel = formatWindowLabel(label, window.windowMinutes) + + return ( + + + {windowLabel} Limit: {bar} {usedPercent.toFixed(0)}% used + + + Resets {formatResetTime(window.resetsAt!)} + + + ) +} + +function formatWindowLabel(base: string, windowMinutes: number | null): string { + if (!windowMinutes) return base + const minutesPerHour = 60 + const minutesPerDay = 24 * minutesPerHour + if (windowMinutes <= minutesPerDay) { + const hours = Math.max(1, Math.round(windowMinutes / minutesPerHour)) + if (hours === 1) return "Hourly" + return `${hours}h` + } + return base +} + +function formatResetTime(resetAt: number): string { + const now = Math.floor(Date.now() / 1000) + const diff = resetAt - now + if (diff <= 0) return "now" + if (diff < 60) return `in ${diff} seconds` + if (diff < 3600) return `in ${Math.round(diff / 60)} minutes` + if (diff < 86400) return `in ${Math.round(diff / 3600)} hours` + return `in ${Math.round(diff / 86400)} days` +} + +function renderProgressBar(usedPercent: number, width = 10): string { + const filled = Math.round((usedPercent / 100) * width) + const empty = width - filled + return `[${"█".repeat(filled)}${"░".repeat(empty)}]` +} + +function formatPlanType(planType: string | null): string { + if (!planType) return "Unknown" + const normalized = planType.replace(/_/g, " ") + const parts: string[] = [] + for (const part of normalized.split(" ")) { + if (!part) continue + parts.push(part.slice(0, 1).toUpperCase() + part.slice(1)) + } + return parts.join(" ") +} + +function formatCreditsLabel( + provider: string, + credits: { hasCredits: boolean; unlimited: boolean; balance: string | null }, +): string { + if (provider === "copilot") { + if (credits.unlimited) return "Quota: Unlimited" + if (credits.balance) return `Quota: ${credits.balance}` + if (!credits.hasCredits) return "Quota: Exhausted" + return "Quota: Available" + } + return `Credits: ${formatCredits(credits)}` +} + +function formatCredits(credits: { hasCredits: boolean; unlimited: boolean; balance: string | null }): string { + if (!credits.hasCredits) return "None" + if (credits.unlimited) return "Unlimited" + if (credits.balance) return credits.balance + return "Available" +} + +function clampPercent(value: number): number { + if (Number.isNaN(value)) return 0 + if (value < 0) return 0 + if (value > 100) return 100 + return value +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 179d92eb611..3d671f5178e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -74,6 +74,7 @@ export function Autocomplete(props: { fileStyleId: number agentStyleId: number promptPartTypeId: () => number + onUsage: (command: string) => void }) { const sdk = useSDK() const sync = useSync() @@ -450,6 +451,11 @@ export function Autocomplete(props: { description: "show status", onSelect: () => command.trigger("opencode.status"), }, + { + display: "/usage", + description: "show usage limits", + onSelect: () => props.onUsage("/usage"), + }, { display: "/mcp", description: "toggle MCPs", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index b250bacd519..a4e5719d262 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -31,6 +31,7 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" +import { DialogUsage, type UsageEntry } from "../dialog-usage" import { useTextareaKeybindings } from "../textarea-keybindings" export type PromptProps = { @@ -90,6 +91,34 @@ export function Prompt(props: PromptProps) { const { theme, syntax } = useTheme() const kv = useKV() + function handleUsageCommand(commandText: string) { + const parts = commandText.trim().split(/\s+/) + const provider = parts.length > 1 && !parts[1].startsWith("-") ? parts[1] : undefined + const refresh = parts.some((part) => part === "--refresh" || part === "-r") + + type UsageResponse = { + entries: UsageEntry[] + error?: string + } + + sdk.client.usage + .get({ provider, refresh }) + .then((res) => { + const data = res.data as UsageResponse | undefined + if (!data) return + if (data.entries.length > 0) { + dialog.replace(() => ) + return + } + const message = data.error ?? "No usage data available." + DialogAlert.show(dialog, "Usage", message) + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + DialogAlert.show(dialog, "Usage", message) + }) + } + function promptModelWarning() { toast.show({ variant: "warning", @@ -161,9 +190,9 @@ export function Prompt(props: PromptProps) { const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent) if (msg.agent && isPrimaryAgent) { local.agent.set(msg.agent) + if (msg.model) local.model.set(msg.model) + if (msg.variant) local.model.variant.set(msg.variant) } - if (msg.model) local.model.set(msg.model) - if (msg.variant) local.model.variant.set(msg.variant) } }) @@ -501,7 +530,7 @@ export function Prompt(props: PromptProps) { async function submit() { if (props.disabled) return - if (autocomplete?.visible) return + if (autocomplete?.visible && !store.prompt.input.startsWith("/usage")) return if (isPasting()) return // Block submit during paste coalescing if (!store.prompt.input) return const trimmed = store.prompt.input.trim() @@ -546,7 +575,16 @@ export function Prompt(props: PromptProps) { const currentMode = store.mode const variant = local.model.variant.current() - if (store.mode === "shell") { + const isShell = store.mode === "shell" + const isUsage = inputText.startsWith("/usage") + const isCommand = + inputText.startsWith("/") && + iife(() => { + const command = inputText.split(" ")[0].slice(1) + return sync.data.command.some((x) => x.name === command) + }) + + if (isShell) { sdk.client.session.shell({ sessionID, agent: local.agent.current().name, @@ -557,15 +595,23 @@ export function Prompt(props: PromptProps) { command: inputText, }) setStore("mode", "normal") - } else if ( - inputText.startsWith("/") && - iife(() => { - const command = inputText.split(" ")[0].slice(1) - console.log(command) - return sync.data.command.some((x) => x.name === command) + } + + if (isUsage) { + handleUsageCommand(inputText) + input.extmarks.clear() + setStore("prompt", { + input: "", + parts: [], }) - ) { - let [command, ...args] = inputText.split(" ") + setStore("extmarkToPartIndex", new Map()) + props.onSubmit?.() + input.clear() + return + } + + if (isCommand) { + const [command, ...args] = inputText.split(" ") sdk.client.session.command({ sessionID, command: command.slice(1), @@ -581,29 +627,30 @@ export function Prompt(props: PromptProps) { ...x, })), }) - } else { - sdk.client.session - .prompt({ - sessionID, - ...selectedModel, - messageID, - agent: local.agent.current().name, - model: selectedModel, - variant, - parts: [ - { - id: Identifier.ascending("part"), - type: "text", - text: inputText, - }, - ...nonTextParts.map((x) => ({ - id: Identifier.ascending("part"), - ...x, - })), - ], - }) - .catch(() => {}) } + + if (!isShell && !isUsage && !isCommand) { + sdk.client.session.prompt({ + sessionID, + ...selectedModel, + messageID, + agent: local.agent.current().name, + model: selectedModel, + variant, + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: inputText, + }, + ...nonTextParts.map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + ], + }) + } + history.append({ ...store.prompt, mode: currentMode, @@ -857,6 +904,7 @@ export function Prompt(props: PromptProps) { fileStyleId={fileStyleId} agentStyleId={agentStyleId} promptPartTypeId={() => promptPartTypeId} + onUsage={handleUsageCommand} /> (anchor = r)} visible={props.visible !== false}> {session()?.share?.url} -{/* Context Section */} + {/* Context Section */} setExpandedWithPersist("context", !expanded.context)}> {expanded.context ? "▼" : "▶"} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index f7d7306d015..f1cdaaa5292 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -161,6 +161,8 @@ export function DialogSelect(props: DialogSelectProps) { if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) if (evt.name === "pageup") move(-10) if (evt.name === "pagedown") move(10) + if (evt.name === "home") moveTo(0) + if (evt.name === "end") moveTo(flat().length - 1) if (evt.name === "return") { const option = selected() if (option) { diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 2c207ecc2f2..5fa2bb42640 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -60,7 +60,11 @@ export const WebCommand = cmd({ } if (opts.mdns) { - UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") + UI.println( + UI.Style.TEXT_INFO_BOLD + " mDNS: ", + UI.Style.TEXT_NORMAL, + `opencode.local:${server.port}`, + ) } // Open localhost in browser diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0a123d3869a..25af4c3c811 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -435,10 +435,6 @@ export namespace Config { .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), scope: z.string().optional().describe("OAuth scopes to request during authorization"), - redirectUri: z - .string() - .optional() - .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), }) .strict() .meta({ diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 76631b92896..0e80b52882d 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -42,7 +42,7 @@ process.on("uncaughtException", (e) => { const cli = yargs(hideBin(process.argv)) .parserConfiguration({ "populate--": true }) - .scriptName("opencode") + .scriptName("shuvcode") .wrap(100) .help("help", "show help") .alias("help", "h") diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 7b9a8c2076a..cbf64294ed3 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -308,8 +308,6 @@ export namespace MCP { let authProvider: McpOAuthProvider | undefined if (!oauthDisabled) { - await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) - authProvider = new McpOAuthProvider( key, mcp.url, @@ -317,7 +315,6 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -347,7 +344,6 @@ export namespace MCP { let lastError: Error | undefined const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT - for (const { name, transport } of transports) { try { const client = new Client({ @@ -387,7 +383,7 @@ export namespace MCP { // Show toast for needs_auth Bus.publish(TuiEvent.ToastShow, { title: "MCP Authentication Required", - message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`, + message: `Server "${key}" requires authentication. Run: shuvcode mcp auth ${key}`, variant: "warning", duration: 8000, }).catch((e) => log.debug("failed to show toast", { error: e })) @@ -574,8 +570,7 @@ export namespace MCP { for (const [clientName, client] of Object.entries(clientsSnapshot)) { // Only include tools from connected MCPs (skip disabled ones) - const clientStatus = s.status[clientName]?.status - if (clientStatus !== "connected") { + if (s.status[clientName]?.status !== "connected") { continue } @@ -725,10 +720,8 @@ export namespace MCP { throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) } - // OAuth config is optional - if not provided, we'll use auto-discovery - const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined - - await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) + // Start the callback server + await McpOAuthCallback.ensureRunning() // Generate and store a cryptographically secure state parameter BEFORE creating the provider // The SDK will call provider.state() to read this value @@ -738,6 +731,8 @@ export namespace MCP { await McpAuth.updateOAuthState(mcpName, oauthState) // Create a new auth provider for this flow + // OAuth config is optional - if not provided, we'll use auto-discovery + const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined let capturedUrl: URL | undefined const authProvider = new McpOAuthProvider( mcpName, @@ -746,7 +741,6 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -775,7 +769,6 @@ export namespace MCP { pendingOAuthTransports.set(mcpName, transport) return { authorizationUrl: capturedUrl.toString() } } - throw error } } @@ -785,9 +778,9 @@ export namespace MCP { * Opens the browser and waits for callback. */ export async function authenticate(mcpName: string): Promise { - const result = await startAuth(mcpName) + const { authorizationUrl } = await startAuth(mcpName) - if (!result.authorizationUrl) { + if (!authorizationUrl) { // Already authenticated const s = await state() return s.status[mcpName] ?? { status: "connected" } @@ -801,9 +794,9 @@ export namespace MCP { // The SDK has already added the state parameter to the authorization URL // We just need to open the browser - log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: oauthState }) + log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState }) try { - const subprocess = await open(result.authorizationUrl) + const subprocess = await open(authorizationUrl) // The open package spawns a detached process and returns immediately. // We need to listen for errors which fire asynchronously: // - "error" event: command not found (ENOENT) @@ -826,7 +819,7 @@ export namespace MCP { // Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers) // Emit event so CLI can display the URL for manual opening log.warn("failed to open browser, user must open URL manually", { mcpName, error }) - Bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }) + Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }) } // Wait for callback using the OAuth state parameter diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index a690ab5e336..bb3b56f2e95 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,12 +1,8 @@ import { Log } from "../util/log" -import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" +import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" const log = Log.create({ service: "mcp.oauth-callback" }) -// Current callback server configuration (may differ from defaults if custom redirectUri is used) -let currentPort = OAUTH_CALLBACK_PORT -let currentPath = OAUTH_CALLBACK_PATH - const HTML_SUCCESS = ` @@ -60,33 +56,21 @@ export namespace McpOAuthCallback { const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes - export async function ensureRunning(redirectUri?: string): Promise { - // Parse the redirect URI to get port and path (uses defaults if not provided) - const { port, path } = parseRedirectUri(redirectUri) - - // If server is running on a different port/path, stop it first - if (server && (currentPort !== port || currentPath !== path)) { - log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port }) - await stop() - } - + export async function ensureRunning(): Promise { if (server) return - const running = await isPortInUse(port) + const running = await isPortInUse() if (running) { - log.info("oauth callback server already running on another instance", { port }) + log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) return } - currentPort = port - currentPath = path - server = Bun.serve({ - port: currentPort, + port: OAUTH_CALLBACK_PORT, fetch(req) { const url = new URL(req.url) - if (url.pathname !== currentPath) { + if (url.pathname !== OAUTH_CALLBACK_PATH) { return new Response("Not found", { status: 404 }) } @@ -149,7 +133,7 @@ export namespace McpOAuthCallback { }, }) - log.info("oauth callback server started", { port: currentPort, path: currentPath }) + log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) } export function waitForCallback(oauthState: string): Promise { @@ -174,11 +158,11 @@ export namespace McpOAuthCallback { } } - export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise { + export async function isPortInUse(): Promise { return new Promise((resolve) => { Bun.connect({ hostname: "127.0.0.1", - port, + port: OAUTH_CALLBACK_PORT, socket: { open(socket) { socket.end() diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 82bad60da33..35ead25e8be 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -17,7 +17,6 @@ export interface McpOAuthConfig { clientId?: string clientSecret?: string scope?: string - redirectUri?: string } export interface McpOAuthCallbacks { @@ -33,10 +32,6 @@ export class McpOAuthProvider implements OAuthClientProvider { ) {} get redirectUrl(): string { - // Use configured redirectUri if provided, otherwise use OpenCode defaults - if (this.config.redirectUri) { - return this.config.redirectUri - } return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` } @@ -157,22 +152,3 @@ export class McpOAuthProvider implements OAuthClientProvider { } export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } - -/** - * Parse a redirect URI to extract port and path for the callback server. - * Returns defaults if the URI can't be parsed. - */ -export function parseRedirectUri(redirectUri?: string): { port: number; path: string } { - if (!redirectUri) { - return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } - } - - try { - const url = new URL(redirectUri) - const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80 - const path = url.pathname || OAUTH_CALLBACK_PATH - return { port, path } - } catch { - return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } - } -} diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index fc172dad939..37b3f35f2e1 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -1,7 +1,12 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import z from "zod" import { Log } from "../util/log" import { OAUTH_DUMMY_KEY } from "../auth" import { ProviderTransform } from "../provider/transform" +import { Bus } from "../bus" +import { TuiEvent } from "../cli/cmd/tui/event" +import { Session } from "../session" +import { Usage, type PlanType, type Snapshot } from "../usage" const log = Log.create({ service: "plugin.codex" }) @@ -10,6 +15,9 @@ const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 +let hasShownUsageToast = false +let hasSubscribedToSession = false + interface PkceCodes { verifier: string challenge: string @@ -345,7 +353,78 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise { + if (response.status !== 429) return + const body = await response + .clone() + .json() + .catch(() => null) + if (!body) return + const parsed = usageLimitErrorSchema.safeParse(body) + if (!parsed.success) return + const planType = parsePlanType(parsed.data.error.plan_type) + await Usage.updateUsage("codex", { + planType, + primary: { + usedPercent: 100, + windowMinutes: null, + resetsAt: parsed.data.error.resets_at, + }, + }) +} + +function showUsageToast(snapshot: Snapshot): void { + const parts: string[] = [] + if (snapshot.primary) { + parts.push(`Hourly: ${snapshot.primary.usedPercent.toFixed(0)}% used`) + } + if (snapshot.secondary) { + parts.push(`Weekly: ${snapshot.secondary.usedPercent.toFixed(0)}% used`) + } + if (parts.length === 0) return + const warning = Usage.getWarning(snapshot) + const variant = warning ? "warning" : "info" + const planLabel = snapshot.planType ? ` (${formatPlanType(snapshot.planType)})` : "" + Bus.publish(TuiEvent.ToastShow, { + title: `OpenAI Usage${planLabel}`, + message: parts.join(" • "), + variant, + duration: 5000, + }).catch((error) => { + const message = error instanceof Error ? error.message : String(error) + log.debug("failed to show usage toast", { error: message }) + }) +} + +function formatPlanType(planType: PlanType): string { + const head = planType.slice(0, 1).toUpperCase() + return head + planType.slice(1) +} + +function parsePlanType(value: string | undefined): PlanType | null { + if (!value) return null + const parsed = Usage.planTypeSchema.safeParse(value) + if (!parsed.success) return null + return parsed.data +} + export async function CodexAuthPlugin(input: PluginInput): Promise { + if (!hasSubscribedToSession) { + Bus.subscribe(Session.Event.Created, () => { + hasShownUsageToast = false + }) + hasSubscribedToSession = true + } + return { auth: { provider: "openai", @@ -448,6 +527,17 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { return fetch(url, { ...init, headers, + }).then(async (response) => { + const usageSnapshot = Usage.parseRateLimitHeaders(response.headers) + if (usageSnapshot) { + const updated = await Usage.updateUsage("codex", usageSnapshot) + if (!hasShownUsageToast && response.ok) { + hasShownUsageToast = true + showUsageToast(updated) + } + } + await handleUsageLimit(response) + return response }) }, } diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 17ce9debc7d..71e6940933d 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -1,8 +1,12 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { Installation } from "@/installation" import { iife } from "@/util/iife" +import { Usage } from "../usage" const CLIENT_ID = "Ov23li8tweQw6odWQebz" +// Add a small safety buffer when polling to avoid hitting the server +// slightly too early due to clock skew / timer drift. +const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 // 3 seconds function normalizeDomain(url: string) { return url.replace(/^https?:\/\//, "").replace(/\/$/, "") @@ -94,10 +98,19 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { delete headers["x-api-key"] delete headers["authorization"] - return fetch(request, { + const response = await fetch(request, { ...init, headers, }) + + if (response.headers.has("x-ratelimit-remaining-tokens")) { + const snapshot = Usage.parseCopilotRateLimitHeaders(response.headers) + if (snapshot) { + Usage.updateUsage("copilot", snapshot).catch(() => {}) + } + } + + return response }, } }, @@ -204,6 +217,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const data = (await response.json()) as { access_token?: string error?: string + interval?: number } if (data.access_token) { @@ -230,13 +244,29 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { } if (data.error === "authorization_pending") { - await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000)) + await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) + continue + } + + if (data.error === "slow_down") { + // Based on the RFC spec, we must add 5 seconds to our current polling interval. + // (See https://www.rfc-editor.org/rfc/rfc8628#section-3.5) + let newInterval = (deviceData.interval + 5) * 1000 + + // GitHub OAuth API may return the new interval in seconds in the response. + // We should try to use that if provided with safety margin. + const serverInterval = data.interval + if (serverInterval && typeof serverInterval === "number" && serverInterval > 0) { + newInterval = serverInterval * 1000 + } + + await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS) continue } if (data.error) return { type: "failed" as const } - await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000)) + await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS) continue } }, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 66091f2a80b..0998962f48b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -120,7 +120,9 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { - if (modelID.includes("codex")) { + // All GPT-5* models support the /responses API with reasoning + // But only GPT-5* models + if (modelID.startsWith("gpt-5")) { return sdk.responses(modelID) } return sdk.chat(modelID) @@ -132,7 +134,7 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { - if (modelID.includes("codex")) { + if (modelID.startsWith("gpt-5")) { return sdk.responses(modelID) } return sdk.chat(modelID) @@ -630,13 +632,13 @@ export namespace Provider { }, experimentalOver200K: model.cost?.context_over_200k ? { - cache: { - read: model.cost.context_over_200k.cache_read ?? 0, - write: model.cost.context_over_200k.cache_write ?? 0, - }, - input: model.cost.context_over_200k.input, - output: model.cost.context_over_200k.output, - } + cache: { + read: model.cost.context_over_200k.cache_read ?? 0, + write: model.cost.context_over_200k.cache_write ?? 0, + }, + input: model.cost.context_over_200k.input, + output: model.cost.context_over_200k.output, + } : undefined, }, limit: { @@ -1020,6 +1022,24 @@ export namespace Provider { opts.signal = combined } + // Strip openai itemId metadata following what codex does + // Codex uses #[serde(skip_serializing)] on id fields for all item types: + // Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall + // IDs are only re-attached for Azure with store=true + if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { + const body = JSON.parse(opts.body as string) + const isAzure = model.providerID.includes("azure") + const keepIds = isAzure && body.store === true + if (!keepIds && Array.isArray(body.input)) { + for (const item of body.input) { + if ("id" in item) { + delete item.id + } + } + opts.body = JSON.stringify(body) + } + } + return fetchFn(input, { ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts index 94b0edaf3f4..007b1c6cd96 100644 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts @@ -815,17 +815,25 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 { // flag that checks if there have been client-side tool calls (not executed by openai) let hasFunctionCall = false + // Track reasoning by output_index instead of item_id + // GitHub Copilot rotates encrypted item IDs on every event const activeReasoning: Record< - string, + number, { + canonicalId: string // the item.id from output_item.added encryptedContent?: string | null summaryParts: number[] } > = {} - // Track a stable text part id for the current assistant message. - // Copilot may change item_id across text deltas; normalize to one id. - let currentTextId: string | null = null + // Track current active reasoning output_index for correlating summary events + // (fallback for providers that omit output_index on summary events). + let currentReasoningOutputIndex: number | null = null + + // Track stable text part ids per output_index. + // Copilot may change item_id across text deltas; normalize to one id per output item. + const activeTextIds: Record = {} + const startedTextItemIds = new Set() let serviceTier: string | undefined @@ -922,7 +930,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 { }) } else if (value.item.type === "message") { // Start a stable text part for this assistant message - currentTextId = value.item.id + activeTextIds[value.output_index] = value.item.id controller.enqueue({ type: "text-start", id: value.item.id, @@ -933,10 +941,12 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 { }, }) } else if (isResponseOutputItemAddedReasoningChunk(value)) { - activeReasoning[value.item.id] = { + activeReasoning[value.output_index] = { + canonicalId: value.item.id, encryptedContent: value.item.encrypted_content, summaryParts: [0], } + currentReasoningOutputIndex = value.output_index controller.enqueue({ type: "reasoning-start", @@ -1083,30 +1093,40 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 { }, }) } else if (value.item.type === "message") { - if (currentTextId) { + const textId = activeTextIds[value.output_index] + if (textId) { + controller.enqueue({ + type: "text-end", + id: textId, + }) + delete activeTextIds[value.output_index] + } else if (startedTextItemIds.has(value.item.id)) { controller.enqueue({ type: "text-end", - id: currentTextId, + id: value.item.id, }) - currentTextId = null + startedTextItemIds.delete(value.item.id) } } else if (isResponseOutputItemDoneReasoningChunk(value)) { - const activeReasoningPart = activeReasoning[value.item.id] + const activeReasoningPart = activeReasoning[value.output_index] if (activeReasoningPart) { for (const summaryIndex of activeReasoningPart.summaryParts) { controller.enqueue({ type: "reasoning-end", - id: `${value.item.id}:${summaryIndex}`, + id: `${activeReasoningPart.canonicalId}:${summaryIndex}`, providerMetadata: { openai: { - itemId: value.item.id, + itemId: activeReasoningPart.canonicalId, reasoningEncryptedContent: value.item.encrypted_content ?? null, }, }, }) } + delete activeReasoning[value.output_index] + if (currentReasoningOutputIndex === value.output_index) { + currentReasoningOutputIndex = null + } } - delete activeReasoning[value.item.id] } } else if (isResponseFunctionCallArgumentsDeltaChunk(value)) { const toolCall = ongoingToolCalls[value.output_index] @@ -1176,21 +1196,38 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 { modelId: value.response.model, }) } else if (isTextDeltaChunk(value)) { - // Ensure a text-start exists, and normalize deltas to a stable id - if (!currentTextId) { - currentTextId = value.item_id - controller.enqueue({ - type: "text-start", - id: currentTextId, - providerMetadata: { - openai: { itemId: value.item_id }, - }, - }) + const outputIndex = value.output_index + let textId: string + + if (typeof outputIndex === "number") { + textId = activeTextIds[outputIndex] ?? value.item_id + if (!activeTextIds[outputIndex]) { + controller.enqueue({ + type: "text-start", + id: textId, + providerMetadata: { + openai: { itemId: value.item_id }, + }, + }) + activeTextIds[outputIndex] = textId + } + } else { + textId = value.item_id + if (!startedTextItemIds.has(textId)) { + startedTextItemIds.add(textId) + controller.enqueue({ + type: "text-start", + id: textId, + providerMetadata: { + openai: { itemId: value.item_id }, + }, + }) + } } controller.enqueue({ type: "text-delta", - id: currentTextId, + id: textId, delta: value.delta, }) @@ -1198,32 +1235,42 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 { logprobs.push(value.logprobs) } } else if (isResponseReasoningSummaryPartAddedChunk(value)) { + const outputIndex = + typeof value.output_index === "number" ? value.output_index : currentReasoningOutputIndex + const activeItem = outputIndex !== null ? activeReasoning[outputIndex] : null + // the first reasoning start is pushed in isResponseOutputItemAddedReasoningChunk. - if (value.summary_index > 0) { - activeReasoning[value.item_id]?.summaryParts.push(value.summary_index) + if (activeItem && value.summary_index > 0) { + activeItem.summaryParts.push(value.summary_index) controller.enqueue({ type: "reasoning-start", - id: `${value.item_id}:${value.summary_index}`, + id: `${activeItem.canonicalId}:${value.summary_index}`, providerMetadata: { openai: { - itemId: value.item_id, - reasoningEncryptedContent: activeReasoning[value.item_id]?.encryptedContent ?? null, + itemId: activeItem.canonicalId, + reasoningEncryptedContent: activeItem.encryptedContent ?? null, }, }, }) } } else if (isResponseReasoningSummaryTextDeltaChunk(value)) { - controller.enqueue({ - type: "reasoning-delta", - id: `${value.item_id}:${value.summary_index}`, - delta: value.delta, - providerMetadata: { - openai: { - itemId: value.item_id, + const outputIndex = + typeof value.output_index === "number" ? value.output_index : currentReasoningOutputIndex + const activeItem = outputIndex !== null ? activeReasoning[outputIndex] : null + + if (activeItem) { + controller.enqueue({ + type: "reasoning-delta", + id: `${activeItem.canonicalId}:${value.summary_index}`, + delta: value.delta, + providerMetadata: { + openai: { + itemId: activeItem.canonicalId, + }, }, - }, - }) + }) + } } else if (isResponseFinishedChunk(value)) { finishReason = mapOpenAIResponseFinishReason({ finishReason: value.response.incomplete_details?.reason, @@ -1262,10 +1309,12 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 { }, flush(controller) { - // Close any dangling text part - if (currentTextId) { - controller.enqueue({ type: "text-end", id: currentTextId }) - currentTextId = null + // Close any dangling text parts + for (const textId of Object.values(activeTextIds)) { + controller.enqueue({ type: "text-end", id: textId }) + } + for (const textId of startedTextItemIds) { + controller.enqueue({ type: "text-end", id: textId }) } const providerMetadata: SharedV2ProviderMetadata = { @@ -1307,6 +1356,8 @@ const usageSchema = z.object({ const textDeltaChunkSchema = z.object({ type: z.literal("response.output_text.delta"), item_id: z.string(), + output_index: z.number().optional(), + content_index: z.number().optional(), delta: z.string(), logprobs: LOGPROBS_SCHEMA.nullish(), }) @@ -1485,12 +1536,14 @@ const responseAnnotationAddedSchema = z.object({ const responseReasoningSummaryPartAddedSchema = z.object({ type: z.literal("response.reasoning_summary_part.added"), item_id: z.string(), + output_index: z.number().optional(), summary_index: z.number(), }) const responseReasoningSummaryTextDeltaSchema = z.object({ type: z.literal("response.reasoning_summary_text.delta"), item_id: z.string(), + output_index: z.number().optional(), summary_index: z.number(), delta: z.string(), }) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 4566fc1de2b..79892db4cca 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -16,38 +16,33 @@ function mimeToModality(mime: string): Modality | undefined { } export namespace ProviderTransform { + // Maps npm package to the key the AI SDK expects for providerOptions + function sdkKey(npm: string): string | undefined { + switch (npm) { + case "@ai-sdk/github-copilot": + case "@ai-sdk/openai": + case "@ai-sdk/azure": + return "openai" + case "@ai-sdk/amazon-bedrock": + return "bedrock" + case "@ai-sdk/anthropic": + return "anthropic" + case "@ai-sdk/google-vertex": + case "@ai-sdk/google": + return "google" + case "@ai-sdk/gateway": + return "gateway" + case "@openrouter/ai-sdk-provider": + return "openrouter" + } + return undefined + } + function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, options: Record, ): ModelMessage[] { - // Strip openai itemId metadata following what codex does - if (model.api.npm === "@ai-sdk/openai" || options.store === false) { - msgs = msgs.map((msg) => { - if (msg.providerOptions) { - for (const options of Object.values(msg.providerOptions)) { - if (options && typeof options === "object") { - delete options["itemId"] - } - } - } - if (!Array.isArray(msg.content)) { - return msg - } - const content = msg.content.map((part) => { - if (part.providerOptions) { - for (const options of Object.values(part.providerOptions)) { - if (options && typeof options === "object") { - delete options["itemId"] - } - } - } - return part - }) - return { ...msg, content } as typeof msg - }) - } - // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content if (model.api.npm === "@ai-sdk/anthropic") { @@ -261,6 +256,28 @@ export namespace ProviderTransform { msgs = applyCaching(msgs, model.providerID) } + // Remap providerOptions keys from stored providerID to expected SDK key + const key = sdkKey(model.api.npm) + if (key && key !== model.providerID && model.api.npm !== "@ai-sdk/azure") { + const remap = (opts: Record | undefined) => { + if (!opts) return opts + if (!(model.providerID in opts)) return opts + const result = { ...opts } + result[key] = result[model.providerID] + delete result[model.providerID] + return result + } + + msgs = msgs.map((msg) => { + if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) } + return { + ...msg, + providerOptions: remap(msg.providerOptions), + content: msg.content.map((part) => ({ ...part, providerOptions: remap(part.providerOptions) })), + } as typeof msg + }) + } + return msgs } @@ -578,39 +595,8 @@ export namespace ProviderTransform { } export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { - switch (model.api.npm) { - case "@ai-sdk/github-copilot": - case "@ai-sdk/openai": - case "@ai-sdk/azure": - return { - ["openai" as string]: options, - } - case "@ai-sdk/amazon-bedrock": - return { - ["bedrock" as string]: options, - } - case "@ai-sdk/anthropic": - return { - ["anthropic" as string]: options, - } - case "@ai-sdk/google-vertex": - case "@ai-sdk/google": - return { - ["google" as string]: options, - } - case "@ai-sdk/gateway": - return { - ["gateway" as string]: options, - } - case "@openrouter/ai-sdk-provider": - return { - ["openrouter" as string]: options, - } - default: - return { - [model.providerID]: options, - } - } + const key = sdkKey(model.api.npm) ?? model.providerID + return { [key]: options } } export function maxOutputTokens( diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 8bddb910503..953269de444 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -7,15 +7,17 @@ export namespace MDNS { let bonjour: Bonjour | undefined let currentPort: number | undefined - export function publish(port: number, name = "opencode") { + export function publish(port: number) { if (currentPort === port) return if (bonjour) unpublish() try { + const name = `opencode-${port}` bonjour = new Bonjour() const service = bonjour.publish({ name, type: "http", + host: "opencode.local", port, txt: { path: "/" }, }) diff --git a/packages/opencode/src/server/routes/tui.ts b/packages/opencode/src/server/routes/tui.ts index be371c1e09e..0577429dd74 100644 --- a/packages/opencode/src/server/routes/tui.ts +++ b/packages/opencode/src/server/routes/tui.ts @@ -119,7 +119,9 @@ export const TuiRoutes = lazy(() => }, }), async (c) => { - // TODO: open dialog + await Bus.publish(TuiEvent.CommandExecute, { + command: "help.show", + }) return c.json(true) }, ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 62240271c8a..c931af714e5 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -21,6 +21,8 @@ import { Agent } from "../agent/agent" import { Skill } from "../skill/skill" import { Auth } from "../auth" import { Flag } from "../flag/flag" +import { Usage } from "../usage" +import type { Snapshot as UsageSnapshot } from "../usage" import { Command } from "../command" import { Global } from "../global" import { ProjectRoutes } from "./routes/project" @@ -61,7 +63,129 @@ export namespace Server { Disposed: BusEvent.define("global.disposed", z.object({})), } + const usageResponseSchema = z.object({ + entries: z.array( + z.object({ + provider: z.string(), + displayName: z.string(), + snapshot: Usage.snapshotSchema, + }), + ), + error: z.string().optional(), + }) + + const UsageRoute = new Hono().get( + "/", + describeRoute({ + summary: "Get usage", + description: "Fetch usage limits for authenticated providers.", + operationId: "usage.get", + responses: { + 200: { + description: "Usage response", + content: { + "application/json": { + schema: resolver(usageResponseSchema), + }, + }, + }, + }, + }), + validator( + "query", + z.object({ + provider: z.string().optional(), + refresh: z.coerce.boolean().optional(), + }), + ), + async (c) => { + const query = c.req.valid("query") + const providerInput = query.provider?.trim() + const refresh = query.refresh ?? false + const resolved = providerInput ? Usage.resolveProvider(providerInput) : null + if (providerInput && !resolved) { + return c.json({ + entries: [], + error: `Unknown provider: "${providerInput}"`, + }) + } + + const providers = resolved ? [resolved] : await Usage.getAuthenticatedProviders() + if (providers.length === 0) { + return c.json({ + entries: [], + error: "No OAuth providers with usage tracking are authenticated. Run: shuvcode auth add codex", + }) + } + + const entries: Array<{ provider: string; displayName: string; snapshot: UsageSnapshot }> = [] + const errors: string[] = [] + + for (const provider of providers) { + const info = Usage.getProviderInfo(provider) + if (!info) { + errors.push(`Provider "${provider}" does not support usage tracking.`) + continue + } + + const authEntry = await Usage.getProviderAuth(provider) + if (!authEntry) { + errors.push(`Not authenticated with ${info.displayName}. Run: shuvcode auth add ${info.authKeys[0]}`) + continue + } + if (info.requiresOAuth && authEntry.auth.type !== "oauth") { + errors.push(`Not authenticated with ${info.displayName} OAuth. Run: shuvcode auth add ${info.authKeys[0]}`) + continue + } + + const accessToken = authEntry.auth.type === "oauth" ? authEntry.auth.access : null + if (!accessToken) { + errors.push(`Missing OAuth access token for ${info.displayName}.`) + continue + } + + const cached = await Usage.getUsage(provider) + const stale = !cached || Date.now() - cached.updatedAt > 5 * 60 * 1000 + const snapshot = await (async () => { + if (!refresh && !stale) return cached + + // Provider-specific fetch logic + let fetched: UsageSnapshot | null = null + + if (provider === "copilot") { + const refreshToken = authEntry.auth.type === "oauth" ? authEntry.auth.refresh : null + if (refreshToken) { + fetched = await Usage.fetchCopilotUsage({ access: accessToken, refresh: refreshToken }) + } + } else { + fetched = await Usage.fetchFromEndpoint(accessToken) + } + + if (!fetched) return cached + return Usage.updateUsage(provider, fetched) + })() + + if (!snapshot) { + errors.push(`Unable to fetch usage data for ${info.displayName}.`) + continue + } + + entries.push({ + provider, + displayName: info.displayName, + snapshot, + }) + } + + return c.json({ + entries, + ...(errors.length > 0 ? { error: errors.join("\n") } : {}), + }) + }, + ) + const app = new Hono() + export const App: () => Hono = lazy( () => // TODO: Break server.ts into smaller route files to fix type inference @@ -272,6 +396,7 @@ export namespace Server { return c.json(commands) }, ) + .route("/usage", UsageRoute) .post( "/log", describeRoute({ @@ -558,7 +683,7 @@ export namespace Server { opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!, `opencode-${server.port!}`) + MDNS.publish(server.port!) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } diff --git a/packages/opencode/src/usage/index.ts b/packages/opencode/src/usage/index.ts new file mode 100644 index 00000000000..3cafdc0309c --- /dev/null +++ b/packages/opencode/src/usage/index.ts @@ -0,0 +1,532 @@ +import z from "zod" +import { Auth } from "../auth/index.js" +import { Bus } from "../bus/index.js" +import { BusEvent } from "../bus/bus-event.js" +import { Storage } from "../storage/storage.js" +import { Log } from "../util/log.js" + +const log = Log.create({ service: "usage" }) + +export const planTypeSchema = z.enum([ + "guest", + "free", + "go", + "plus", + "pro", + "free_workspace", + "team", + "business", + "education", + "quorum", + "k12", + "enterprise", + "edu", +]) +export type PlanType = z.infer + +export const rateLimitWindowSchema = z.object({ + usedPercent: z.number(), + windowMinutes: z.number().nullable(), + resetsAt: z.number().nullable(), +}) +export type RateLimitWindow = z.infer + +export const creditsSnapshotSchema = z.object({ + hasCredits: z.boolean(), + unlimited: z.boolean(), + balance: z.string().nullable(), +}) +export type CreditsSnapshot = z.infer + +export const snapshotSchema = z.object({ + primary: rateLimitWindowSchema.nullable(), + secondary: rateLimitWindowSchema.nullable(), + credits: creditsSnapshotSchema.nullable(), + planType: planTypeSchema.nullable(), + updatedAt: z.number(), +}) +export type Snapshot = z.infer + +export const UsageEvent = { + Updated: BusEvent.define( + "usage.updated", + z.object({ + provider: z.string(), + snapshot: snapshotSchema, + }), + ), +} + +type UsageProviderInfo = { + authKeys: string[] + displayName: string + requiresOAuth: boolean +} + +const usageProviders: Record = { + codex: { + authKeys: ["openai", "codex"], + displayName: "OpenAI", + requiresOAuth: true, + }, + copilot: { + authKeys: ["github-copilot", "github-copilot-enterprise"], + displayName: "GitHub Copilot", + requiresOAuth: true, + }, +} + +const providerAliases: Record = { + openai: "codex", + gpt: "codex", + chatgpt: "codex", + "chatgpt-pro": "codex", + "chatgpt-plus": "codex", + codex: "codex", + copilot: "copilot", + github: "copilot", + gh: "copilot", +} + +const usageEndpoint = "https://chatgpt.com/backend-api/wham/usage" + +export const warningThresholds = [75, 90, 95] as const + +export async function getUsage(provider: string): Promise { + return Storage.read(storageKey(provider)).catch(() => null) +} + +export async function updateUsage(provider: string, update: Partial): Promise { + const existing = await getUsage(provider) + const snapshot: Snapshot = { + primary: update.primary ?? existing?.primary ?? null, + secondary: update.secondary ?? existing?.secondary ?? null, + credits: update.credits ?? existing?.credits ?? null, + planType: update.planType ?? existing?.planType ?? null, + updatedAt: Date.now(), + } + await Storage.write(storageKey(provider), snapshot) + await Bus.publish(UsageEvent.Updated, { provider, snapshot }).catch(() => {}) + return snapshot +} + +export async function clearUsage(provider: string): Promise { + await Storage.remove(storageKey(provider)) +} + +export function resolveProvider(input: string): string | null { + const normalized = input.trim().toLowerCase() + return providerAliases[normalized] ?? null +} + +export function listSupportedProviders(): string[] { + return Object.keys(usageProviders) +} + +export function getProviderInfo(provider: string): UsageProviderInfo | null { + return usageProviders[provider] ?? null +} + +export async function getAuthenticatedProviders(): Promise { + const auth = await Auth.all() + const providers = Object.keys(usageProviders) + const result: string[] = [] + + for (const provider of providers) { + const info = usageProviders[provider] + const matched = info.authKeys.some((key) => { + const providerAuth = auth[key] + if (!providerAuth) return false + if (info.requiresOAuth && providerAuth.type !== "oauth") return false + return true + }) + if (matched) result.push(provider) + } + + return result +} + +export async function getProviderAuth(provider: string): Promise<{ key: string; auth: Auth.Info } | null> { + const info = usageProviders[provider] + if (!info) return null + const auth = await Auth.all() + + for (const key of info.authKeys) { + const providerAuth = auth[key] + if (!providerAuth) continue + if (info.requiresOAuth && providerAuth.type !== "oauth") continue + return { key, auth: providerAuth } + } + + return null +} + +export function parseRateLimitHeaders(headers: Headers): Snapshot | null { + const primary = parseWindow(headers, "primary") + const secondary = parseWindow(headers, "secondary") + const credits = parseCredits(headers) + if (!primary && !secondary && !credits) return null + + return { + primary, + secondary, + credits, + planType: null, + updatedAt: Date.now(), + } +} + +export async function fetchFromEndpoint(accessToken: string): Promise { + const response = await fetch(usageEndpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!response.ok) { + log.warn("usage fetch failed", { status: response.status }) + return null + } + + const body = await response.json().catch(() => null) + if (!body) return null + + const parsed = usageResponseSchema.safeParse(body) + if (!parsed.success) { + log.warn("usage fetch parse failed", { issues: parsed.error.issues.length }) + return null + } + + const rateLimit = parsed.data.rate_limit + const primary = toRateLimitWindow(rateLimit.primary_window) + const secondary = toRateLimitWindow(rateLimit.secondary_window) + const credits = toCreditsSnapshot(parsed.data.credits) + const planType = toPlanType(parsed.data.plan_type) + + return { + primary, + secondary, + credits, + planType, + updatedAt: Date.now(), + } +} + +export function formatResetTime(resetAt: number): string { + const now = Math.floor(Date.now() / 1000) + const diff = resetAt - now + if (diff <= 0) return "now" + if (diff < 60) return `in ${diff} seconds` + if (diff < 3600) return `in ${Math.round(diff / 60)} minutes` + if (diff < 86400) return `in ${Math.round(diff / 3600)} hours` + return `in ${Math.round(diff / 86400)} days` +} + +export function formatWindowDuration(windowMinutes: number): string { + const minutesPerHour = 60 + const minutesPerDay = 24 * minutesPerHour + if (windowMinutes <= minutesPerDay) { + const hours = Math.max(1, Math.round(windowMinutes / minutesPerHour)) + if (hours === 1) return "Hourly" + return `${hours}h` + } + return "Weekly" +} + +export function getWarning(snapshot: Snapshot): string | null { + const windows = [snapshot.primary, snapshot.secondary] + for (const window of windows) { + if (!window) continue + for (const threshold of warningThresholds) { + if (window.usedPercent < threshold) continue + const remaining = 100 - window.usedPercent + const duration = formatWindowDuration(window.windowMinutes ?? 60).toLowerCase() + return `Less than ${remaining.toFixed(0)}% of your ${duration} limit remaining.` + } + } + return null +} + +function storageKey(provider: string): string[] { + return ["usage", provider] +} + +function parseWindow(headers: Headers, prefix: "primary" | "secondary"): RateLimitWindow | null { + const usedPercent = parseNumberHeader(headers, `x-codex-${prefix}-used-percent`) + if (usedPercent === null) return null + + return { + usedPercent, + windowMinutes: parseIntegerHeader(headers, `x-codex-${prefix}-window-minutes`), + resetsAt: parseIntegerHeader(headers, `x-codex-${prefix}-reset-at`), + } +} + +function parseCredits(headers: Headers): CreditsSnapshot | null { + const hasCredits = parseBooleanHeader(headers, "x-codex-credits-has-credits") + if (hasCredits === null) return null + + return { + hasCredits, + unlimited: parseBooleanHeader(headers, "x-codex-credits-unlimited") ?? false, + balance: headers.get("x-codex-credits-balance"), + } +} + +function parseNumberHeader(headers: Headers, name: string): number | null { + const value = headers.get(name) + if (!value) return null + const parsed = Number.parseFloat(value) + if (Number.isNaN(parsed)) return null + return parsed +} + +function parseIntegerHeader(headers: Headers, name: string): number | null { + const value = headers.get(name) + if (!value) return null + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed)) return null + return parsed +} + +function parseBooleanHeader(headers: Headers, name: string): boolean | null { + const value = headers.get(name) + if (!value) return null + const normalized = value.toLowerCase() + if (normalized === "true" || normalized === "1") return true + if (normalized === "false" || normalized === "0") return false + return null +} + +type UsageResponseWindow = { + used_percent: number + limit_window_seconds: number + reset_after_seconds: number + reset_at: number +} + +type UsageResponse = { + plan_type: string | null + rate_limit: { + allowed: boolean + limit_reached: boolean + primary_window: UsageResponseWindow | null + secondary_window: UsageResponseWindow | null + } + credits: { + has_credits: boolean + unlimited: boolean + balance: string | null + } | null +} + +const usageResponseWindowSchema = z.object({ + used_percent: z.number(), + limit_window_seconds: z.number(), + reset_after_seconds: z.number(), + reset_at: z.number(), +}) + +const usageResponseSchema = z.object({ + plan_type: z.string().nullable(), + rate_limit: z.object({ + allowed: z.boolean(), + limit_reached: z.boolean(), + primary_window: usageResponseWindowSchema.nullable(), + secondary_window: usageResponseWindowSchema.nullable(), + }), + credits: z + .object({ + has_credits: z.boolean(), + unlimited: z.boolean(), + balance: z.string().nullable(), + }) + .nullable(), +}) satisfies z.ZodType + +function toRateLimitWindow(window: UsageResponseWindow | null): RateLimitWindow | null { + if (!window) return null + return { + usedPercent: window.used_percent, + windowMinutes: Math.round(window.limit_window_seconds / 60), + resetsAt: window.reset_at, + } +} + +function toCreditsSnapshot(credits: UsageResponse["credits"]): CreditsSnapshot | null { + if (!credits) return null + return { + hasCredits: credits.has_credits, + unlimited: credits.unlimited, + balance: credits.balance, + } +} + +function toPlanType(value: UsageResponse["plan_type"]): PlanType | null { + if (!value) return null + const parsed = planTypeSchema.safeParse(value) + if (!parsed.success) return null + return parsed.data +} + +type CopilotTokenMetadata = { + tid?: string + exp?: number + sku?: string + proxyEndpoint?: string + quotaLimit?: number + resetDate?: number +} + +const COPILOT_SKU_PLAN_MAP: Record = { + free_limited_copilot: "free", + copilot_for_individual: "pro", + copilot_individual: "pro", + copilot_business: "business", + copilot_enterprise: "enterprise", + copilot_for_business: "business", +} + +export function parseCopilotAccessToken(accessToken: string): CopilotTokenMetadata { + const result: CopilotTokenMetadata = {} + const parts = accessToken.split(";") + + for (const part of parts) { + const eqIndex = part.indexOf("=") + if (eqIndex === -1) continue + const key = part.slice(0, eqIndex) + const value = part.slice(eqIndex + 1) + + switch (key) { + case "tid": + result.tid = value + break + case "exp": + result.exp = Number.parseInt(value, 10) + break + case "sku": + result.sku = value + break + case "proxy-ep": + result.proxyEndpoint = value + break + case "cq": + result.quotaLimit = Number.parseInt(value, 10) + break + case "rd": { + const colonIdx = value.indexOf(":") + if (colonIdx > 0) { + result.resetDate = Number.parseInt(value.slice(0, colonIdx), 10) + } + break + } + } + } + + return result +} + +export function copilotSkuToPlan(sku: string | undefined): PlanType | null { + if (!sku) return null + return COPILOT_SKU_PLAN_MAP[sku] ?? copilotSkuToPlanType(sku) +} + +export function parseCopilotRateLimitHeaders(headers: Headers): Snapshot | null { + const remainingTokens = parseIntegerHeader(headers, "x-ratelimit-remaining-tokens") + const remainingRequests = parseIntegerHeader(headers, "x-ratelimit-remaining-requests") + + if (remainingTokens === null && remainingRequests === null) return null + + const estimatedTokenLimit = 10_000_000 + const estimatedRequestLimit = 200_000 + + const primary: RateLimitWindow | null = + remainingTokens !== null + ? { + usedPercent: Math.max( + 0, + Math.min(100, ((estimatedTokenLimit - remainingTokens) / estimatedTokenLimit) * 100), + ), + windowMinutes: 60, + resetsAt: null, + } + : null + + const secondary: RateLimitWindow | null = + remainingRequests !== null + ? { + usedPercent: Math.max( + 0, + Math.min(100, ((estimatedRequestLimit - remainingRequests) / estimatedRequestLimit) * 100), + ), + windowMinutes: null, + resetsAt: null, + } + : null + + return { + primary, + secondary, + credits: null, + planType: null, + updatedAt: Date.now(), + } +} + +type CopilotAuthInfo = { + access: string + refresh: string +} + +export async function fetchCopilotUsage(auth: CopilotAuthInfo): Promise { + const tokenMetadata = parseCopilotAccessToken(auth.access) + const planType = copilotSkuToPlan(tokenMetadata.sku) + + return { + primary: null, + secondary: null, + credits: tokenMetadata.quotaLimit + ? { + hasCredits: true, + unlimited: false, + balance: String(tokenMetadata.quotaLimit), + } + : null, + planType, + updatedAt: Date.now(), + } +} + +function copilotSkuToPlanType(sku: string): PlanType | null { + const normalized = sku.toLowerCase() + if (normalized.includes("free")) return "free" + if (normalized.includes("individual") || normalized.includes("pro")) return "pro" + if (normalized.includes("business")) return "business" + if (normalized.includes("enterprise")) return "enterprise" + return null +} + +export const Usage = { + planTypeSchema, + rateLimitWindowSchema, + creditsSnapshotSchema, + snapshotSchema, + warningThresholds, + getUsage, + updateUsage, + clearUsage, + resolveProvider, + listSupportedProviders, + getProviderInfo, + getAuthenticatedProviders, + getProviderAuth, + parseRateLimitHeaders, + fetchFromEndpoint, + formatResetTime, + formatWindowDuration, + getWarning, + parseCopilotAccessToken, + parseCopilotRateLimitHeaders, + copilotSkuToPlan, + fetchCopilotUsage, +} as const diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts deleted file mode 100644 index aa23f4dfb5d..00000000000 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { test, expect, describe, afterEach } from "bun:test" -import { McpOAuthCallback } from "../../src/mcp/oauth-callback" -import { parseRedirectUri } from "../../src/mcp/oauth-provider" - -describe("McpOAuthCallback.ensureRunning", () => { - afterEach(async () => { - await McpOAuthCallback.stop() - }) - - test("starts server with default config when no redirectUri provided", async () => { - await McpOAuthCallback.ensureRunning() - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("starts server with custom redirectUri", async () => { - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("is idempotent when called with same redirectUri", async () => { - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback") - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback") - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("restarts server when redirectUri changes", async () => { - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18002/path1") - expect(McpOAuthCallback.isRunning()).toBe(true) - - await McpOAuthCallback.ensureRunning("http://127.0.0.1:18003/path2") - expect(McpOAuthCallback.isRunning()).toBe(true) - }) - - test("isRunning returns false when not started", async () => { - expect(McpOAuthCallback.isRunning()).toBe(false) - }) - - test("isRunning returns false after stop", async () => { - await McpOAuthCallback.ensureRunning() - await McpOAuthCallback.stop() - expect(McpOAuthCallback.isRunning()).toBe(false) - }) -}) - -describe("parseRedirectUri", () => { - test("returns defaults when no URI provided", () => { - const result = parseRedirectUri() - expect(result.port).toBe(19876) - expect(result.path).toBe("/mcp/oauth/callback") - }) - - test("parses port and path from URI", () => { - const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback") - expect(result.port).toBe(8080) - expect(result.path).toBe("/oauth/callback") - }) - - test("defaults to port 80 for http without explicit port", () => { - const result = parseRedirectUri("http://127.0.0.1/callback") - expect(result.port).toBe(80) - expect(result.path).toBe("/callback") - }) - - test("defaults to port 443 for https without explicit port", () => { - const result = parseRedirectUri("https://127.0.0.1/callback") - expect(result.port).toBe(443) - expect(result.path).toBe("/callback") - }) - - test("returns defaults for invalid URI", () => { - const result = parseRedirectUri("not-a-valid-url") - expect(result.port).toBe(19876) - expect(result.path).toBe("/mcp/oauth/callback") - }) -}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 33047b5bcb4..dcf16c65cbd 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -649,7 +649,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( headers: {}, } as any - test("strips itemId and reasoningEncryptedContent when store=false", () => { + test("preserves itemId and reasoningEncryptedContent when store=false", () => { const msgs = [ { role: "assistant", @@ -680,11 +680,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") }) - test("strips itemId and reasoningEncryptedContent when store=false even when not openai", () => { + test("preserves itemId and reasoningEncryptedContent when store=false even when not openai", () => { const zenModel = { ...openaiModel, providerID: "zen", @@ -719,11 +719,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") }) - test("preserves other openai options when stripping itemId", () => { + test("preserves other openai options including itemId", () => { const msgs = [ { role: "assistant", @@ -744,11 +744,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value") }) - test("strips metadata for openai package even when store is true", () => { + test("preserves metadata for openai package when store is true", () => { const msgs = [ { role: "assistant", @@ -766,13 +766,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // openai package always strips itemId regardless of store value + // openai package preserves itemId regardless of store value const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") }) - test("strips metadata for non-openai packages when store is false", () => { + test("preserves metadata for non-openai packages when store is false", () => { const anthropicModel = { ...openaiModel, providerID: "anthropic", @@ -799,13 +799,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // store=false triggers stripping even for non-openai packages + // store=false preserves metadata for non-openai packages const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") }) - test("strips metadata using providerID key when store is false", () => { + test("preserves metadata using providerID key when store is false", () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -835,11 +835,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_123") expect(result[0].content[0].providerOptions?.opencode?.otherOption).toBe("value") }) - test("strips itemId across all providerOptions keys", () => { + test("preserves itemId across all providerOptions keys", () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -873,12 +873,12 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] - expect(result[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].providerOptions?.opencode?.itemId).toBeUndefined() - expect(result[0].providerOptions?.extra?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.extra?.itemId).toBeUndefined() + expect(result[0].providerOptions?.openai?.itemId).toBe("msg_root") + expect(result[0].providerOptions?.opencode?.itemId).toBe("msg_opencode") + expect(result[0].providerOptions?.extra?.itemId).toBe("msg_extra") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_openai_part") + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_opencode_part") + expect(result[0].content[0].providerOptions?.extra?.itemId).toBe("msg_extra_part") }) test("does not strip metadata for non-openai packages when store is not false", () => { @@ -914,6 +914,88 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }) }) +describe("ProviderTransform.message - providerOptions key remapping", () => { + const createModel = (providerID: string, npm: string) => + ({ + id: `${providerID}/test-model`, + providerID, + api: { + id: "test-model", + url: "https://api.test.com", + npm, + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: true, + toolcall: true, + input: { text: true, audio: false, image: true, video: false, pdf: true }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + }) as any + + test("azure keeps 'azure' key and does not remap to 'openai'", () => { + const model = createModel("azure", "@ai-sdk/azure") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + azure: { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.azure).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.openai).toBeUndefined() + }) + + test("openai with github-copilot npm remaps providerID to 'openai'", () => { + const model = createModel("github-copilot", "@ai-sdk/github-copilot") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + "github-copilot": { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.openai).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.["github-copilot"]).toBeUndefined() + }) + + test("bedrock remaps providerID to 'bedrock' key", () => { + const model = createModel("my-bedrock", "@ai-sdk/amazon-bedrock") + const msgs = [ + { + role: "user", + content: "Hello", + providerOptions: { + "my-bedrock": { someOption: "value" }, + }, + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].providerOptions?.bedrock).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.["my-bedrock"]).toBeUndefined() + }) +}) + describe("ProviderTransform.variants", () => { const createMockModel = (overrides: Partial = {}): any => ({ id: "test/test-model", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a5b714d84c6..a458626edfa 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a84f9855cc5..571e1522031 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a4a446de2cb..67b4e70ec97 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1918,6 +1918,83 @@ export class Question extends HeyApiClient { } } +export class Usage extends HeyApiClient { + /** + * Get usage + * + * Fetch usage limits for authenticated providers. + */ + public get( + parameters?: { + directory?: string + provider?: string + refresh?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "provider" }, + { in: "query", key: "refresh" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + { + entries: Array<{ + provider: string + displayName: string + snapshot: { + primary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + secondary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + credits: { + hasCredits: boolean + unlimited: boolean + balance: string | null + } | null + planType: + | "guest" + | "free" + | "go" + | "plus" + | "pro" + | "free_workspace" + | "team" + | "business" + | "education" + | "quorum" + | "k12" + | "enterprise" + | "edu" + | null + updatedAt: number + } + }> + error?: string + }, + unknown, + ThrowOnError + >({ + url: "/usage", + ...options, + ...params, + }) + } +} + export class Oauth extends HeyApiClient { /** * OAuth authorize @@ -3168,6 +3245,11 @@ export class OpencodeClient extends HeyApiClient { return (this._mcp ??= new Mcp({ client: this.client })) } + private _usage?: Usage + get usage(): Usage { + return (this._usage ??= new Usage({ client: this.client })) + } + private _tui?: Tui get tui(): Tui { return (this._tui ??= new Tui({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4c6187ba3a6..4f9fcb6c36a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -883,6 +883,46 @@ export type EventVcsBranchUpdated = { } } +export type EventUsageUpdated = { + type: "usage.updated" + properties: { + provider: string + snapshot: { + primary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + secondary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + credits: { + hasCredits: boolean + unlimited: boolean + balance: string | null + } | null + planType: + | "guest" + | "free" + | "go" + | "plus" + | "pro" + | "free_workspace" + | "team" + | "business" + | "education" + | "quorum" + | "k12" + | "enterprise" + | "edu" + | null + updatedAt: number + } + } +} + export type Pty = { id: string title: string @@ -974,6 +1014,7 @@ export type Event = | EventSessionError | EventFileWatcherUpdated | EventVcsBranchUpdated + | EventUsageUpdated | EventPtyCreated | EventPtyUpdated | EventPtyExited @@ -1645,10 +1686,6 @@ export type McpOAuthConfig = { * OAuth scopes to request during authorization */ scope?: string - /** - * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). - */ - redirectUri?: string } export type McpRemoteConfig = { @@ -3911,6 +3948,83 @@ export type QuestionRejectResponses = { export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] +export type CommandListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/command" +} + +export type CommandListResponses = { + /** + * List of commands + */ + 200: Array +} + +export type CommandListResponse = CommandListResponses[keyof CommandListResponses] + +export type UsageGetData = { + body?: never + path?: never + query?: { + directory?: string + provider?: string + refresh?: boolean + } + url: "/usage" +} + +export type UsageGetResponses = { + /** + * Usage response + */ + 200: { + entries: Array<{ + provider: string + displayName: string + snapshot: { + primary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + secondary: { + usedPercent: number + windowMinutes: number | null + resetsAt: number | null + } | null + credits: { + hasCredits: boolean + unlimited: boolean + balance: string | null + } | null + planType: + | "guest" + | "free" + | "go" + | "plus" + | "pro" + | "free_workspace" + | "team" + | "business" + | "education" + | "quorum" + | "k12" + | "enterprise" + | "edu" + | null + updatedAt: number + } + }> + error?: string + } +} + +export type UsageGetResponse = UsageGetResponses[keyof UsageGetResponses] + export type ProviderListData = { body?: never path?: never @@ -4805,24 +4919,6 @@ export type VcsGetResponses = { export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] -export type CommandListData = { - body?: never - path?: never - query?: { - directory?: string - } - url: "/command" -} - -export type CommandListResponses = { - /** - * List of commands - */ - 200: Array -} - -export type CommandListResponse = CommandListResponses[keyof CommandListResponses] - export type AppLogData = { body?: { /** diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 2ff72a7a0d2..d2c74fc5dd3 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3507,6 +3507,59 @@ ] } }, + "/config/providers": { + + "get": { + "operationId": "config.providers", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "List config providers", + "description": "Get a list of all configured AI providers and their default models.", + "responses": { + "200": { + "description": "List of providers", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Provider" + } + }, + "default": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["providers", "default"] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.providers({\n ...\n})" + } + ] + } + }, "/provider": { "get": { "operationId": "provider.list", @@ -8818,6 +8871,160 @@ "properties" ] }, + "Event.usage.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "usage.updated" + }, + "properties": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "snapshot": { + "type": "object", + "properties": { + "primary": { + "anyOf": [ + { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "resetsAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["usedPercent", "windowMinutes", "resetsAt"] + }, + { + "type": "null" + } + ] + }, + "secondary": { + "anyOf": [ + { + "type": "object", + "properties": { + "usedPercent": { + "type": "number" + }, + "windowMinutes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "resetsAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": ["usedPercent", "windowMinutes", "resetsAt"] + }, + { + "type": "null" + } + ] + }, + "credits": { + "anyOf": [ + { + "type": "object", + "properties": { + "hasCredits": { + "type": "boolean" + }, + "unlimited": { + "type": "boolean" + }, + "balance": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["hasCredits", "unlimited", "balance"] + }, + { + "type": "null" + } + ] + }, + "planType": { + "anyOf": [ + { + "type": "string", + "enum": [ + "guest", + "free", + "go", + "plus", + "pro", + "free_workspace", + "team", + "business", + "education", + "quorum", + "k12", + "enterprise", + "edu" + ] + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "number" + } + }, + "required": ["primary", "secondary", "credits", "planType", "updatedAt"] + } + }, + "required": ["provider", "snapshot"] + } + }, + "required": ["type", "properties"] + }, "Pty": { "type": "object", "properties": { @@ -9110,6 +9317,9 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.usage.updated" + }, { "$ref": "#/components/schemas/Event.pty.created" }, @@ -10260,10 +10470,6 @@ "scope": { "description": "OAuth scopes to request during authorization", "type": "string" - }, - "redirectUri": { - "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", - "type": "string" } }, "additionalProperties": false @@ -12057,4 +12263,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 327af958ac3..3f894bea8c2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -59,6 +59,7 @@ "shiki": "catalog:", "solid-js": "catalog:", "solid-list": "catalog:", + "strip-ansi": "7.1.2", "virtua": "catalog:" } } diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 22692257333..a18306623bc 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -24,7 +24,7 @@ [data-slot="dialog-container"] { position: relative; z-index: 50; - width: min(calc(100vw - 16px), 480px); + width: min(calc(100vw - 16px), 640px); height: min(calc(100vh - 16px), 512px); display: flex; flex-direction: column; diff --git a/packages/ui/src/components/keybind.css b/packages/ui/src/components/keybind.css new file mode 100644 index 00000000000..1a9e5dce43e --- /dev/null +++ b/packages/ui/src/components/keybind.css @@ -0,0 +1,18 @@ +[data-component="keybind"] { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + height: 20px; + padding: 0 8px; + border-radius: 2px; + background: var(--surface-base); + box-shadow: var(--shadow-xxs-border); + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: 12px; + font-weight: var(--font-weight-medium); + line-height: 1; + color: var(--text-weak); +} diff --git a/packages/ui/src/components/keybind.tsx b/packages/ui/src/components/keybind.tsx new file mode 100644 index 00000000000..a0fa0483fd9 --- /dev/null +++ b/packages/ui/src/components/keybind.tsx @@ -0,0 +1,20 @@ +import type { ComponentProps, ParentProps } from "solid-js" + +export interface KeybindProps extends ParentProps { + class?: string + classList?: ComponentProps<"span">["classList"] +} + +export function Keybind(props: KeybindProps) { + return ( + + {props.children} + + ) +} diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 651f5ef971e..ee9be422c97 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -56,11 +56,14 @@ width: 20px; height: 20px; background-color: transparent; + opacity: 0.5; + transition: opacity 0.15s ease; &:hover:not(:disabled), &:focus:not(:disabled), &:active:not(:disabled) { background-color: transparent; + opacity: 0.7; } &:hover:not(:disabled) [data-slot="icon-svg"] { @@ -91,7 +94,7 @@ [data-slot="list-empty-state"] { display: flex; - padding: 32px 0px; + padding: 32px 48px; flex-direction: column; justify-content: center; align-items: center; @@ -103,8 +106,9 @@ justify-content: center; align-items: center; gap: 2px; + max-width: 100%; color: var(--text-weak); - text-align: center; + white-space: nowrap; /* text-14-regular */ font-family: var(--font-family-sans); @@ -117,6 +121,8 @@ [data-slot="list-filter"] { color: var(--text-strong); + overflow: hidden; + text-overflow: ellipsis; } } @@ -125,10 +131,14 @@ display: flex; flex-direction: column; + &:last-child { + padding-bottom: 12px; + } + [data-slot="list-header"] { display: flex; z-index: 10; - padding: 0 12px 8px 8px; + padding: 8px 12px 8px 12px; justify-content: space-between; align-items: center; align-self: stretch; @@ -136,7 +146,7 @@ position: sticky; top: 0; - color: var(--text-base); + color: var(--text-weak); /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 3963acfdd25..67545757d99 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -8,6 +8,8 @@ import { TextField } from "./text-field" export interface ListSearchProps { placeholder?: string autofocus?: boolean + hideIcon?: boolean + class?: string } export interface ListProps extends FilteredListProps { @@ -68,7 +70,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) if (!props.current) return const key = props.key(props.current) requestAnimationFrame(() => { - const element = scrollRef()?.querySelector(`[data-key="${key}"]`) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`) element?.scrollIntoView({ block: "center" }) }) }) @@ -80,8 +82,8 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) scrollRef()?.scrollTo(0, 0) return } - const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) - element?.scrollIntoView({ block: "center", behavior: "smooth" }) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`) + element?.scrollIntoView({ block: "center" }) }) createEffect(() => { @@ -148,9 +150,11 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return (
-
+
- + + +
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 8ab4d6ca4d0..b4b0883aeb0 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -24,6 +24,7 @@ @import "../components/icon.css" layer(components); @import "../components/icon-button.css" layer(components); @import "../components/image-preview.css" layer(components); +@import "../components/keybind.css" layer(components); @import "../components/text-field.css" layer(components); @import "../components/inline-input.css" layer(components); @import "../components/list.css" layer(components); diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 44a73de69e9..ce3e3deb86c 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -47,16 +47,17 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw ## Projects -| Name | Description | -| ------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | -| [kimaki](https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | -| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | -| [portal](https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | -| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | Template for building OpenCode plugins | -| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | Neovim frontend for opencode - a terminal-based AI coding agent | -| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK provider for using OpenCode via @opencode-ai/sdk | -| [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App and VS Code Extension for OpenCode | -| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian plugin that embedds OpenCode in Obsidian's UI | +| Name | Description | +| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| [kimaki](https://github.com/remorses/kimaki) | Discord bot to control OpenCode sessions, built on the SDK | +| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | Neovim plugin for editor-aware prompts, built on the API | +| [portal](https://github.com/hosenur/portal) | Mobile-first web UI for OpenCode over Tailscale/VPN | +| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | Template for building OpenCode plugins | +| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | Neovim frontend for opencode - a terminal-based AI coding agent | +| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | Vercel AI SDK provider for using OpenCode via @opencode-ai/sdk | +| [OpenChamber](https://github.com/btriapitsyn/openchamber) | Web / Desktop App and VS Code Extension for OpenCode | +| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | Obsidian plugin that embedds OpenCode in Obsidian's UI | +| [OpenWork](https://github.com/different-ai/openwork) | An open-source alternative to Claude Cowork, powered by OpenCode | --- diff --git a/script/sync/fork-features.json b/script/sync/fork-features.json index ac38532b95f..d8ff37cf8ea 100644 --- a/script/sync/fork-features.json +++ b/script/sync/fork-features.json @@ -1,9 +1,9 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "description": "Fork-specific features from upstream PRs that must be preserved during merges", - "lastUpdated": "2026-01-15", - "lastChange": "v1.1.23 sync: preserve shuvcode branding (TUI logo, tauri window), inline theme preload script, and update merge notes.", - "note": "v1.1.23 sync. Upstream adds new console UI, snapshot updates, and install method tweaks; keep fork branding and theme preload behavior.", + "lastUpdated": "2026-01-19", + "lastChange": "Add CLI binary name branding: scriptName 'shuvcode' and all user-facing command references updated from 'opencode' to 'shuvcode'.", + "note": "v1.1.25 sync. CLI --help now shows 'shuvcode' instead of 'opencode' for all command names and help text.", "forkDependencies": { "description": "NPM dependencies added by fork features that MUST be preserved during package.json merges. These are frequently lost when accepting upstream version bumps.", "packages/opencode/package.json": [ @@ -1583,6 +1583,47 @@ } ] }, + { + "pr": 0, + "title": "CLI binary name and help text branding", + "author": "fork", + "status": "fork-only", + "description": "Rebrand CLI binary name from 'opencode' to 'shuvcode' in --help output and all user-facing command suggestions. The yargs scriptName is set to 'shuvcode' and all help text references like 'Run: opencode auth add' are changed to 'Run: shuvcode auth add'.", + "files": [ + "packages/opencode/src/index.ts", + "packages/opencode/src/server/server.ts", + "packages/opencode/src/mcp/index.ts", + "packages/opencode/src/acp/agent.ts", + "packages/opencode/src/cli/cmd/mcp.ts" + ], + "criticalCode": [ + { + "file": "packages/opencode/src/index.ts", + "description": "Yargs scriptName set to shuvcode for --help output", + "markers": [".scriptName(\"shuvcode\")"] + }, + { + "file": "packages/opencode/src/server/server.ts", + "description": "Usage tracking auth error messages reference shuvcode", + "markers": ["Run: shuvcode auth add"] + }, + { + "file": "packages/opencode/src/mcp/index.ts", + "description": "MCP auth required toast message references shuvcode", + "markers": ["Run: shuvcode mcp auth"] + }, + { + "file": "packages/opencode/src/acp/agent.ts", + "description": "ACP auth method description and terminal-auth command use shuvcode", + "markers": ["Run `shuvcode auth login`", "command: \"shuvcode\"", "label: \"Shuvcode Login\""] + }, + { + "file": "packages/opencode/src/cli/cmd/mcp.ts", + "description": "MCP list command outro references shuvcode", + "markers": ["Add servers with: shuvcode mcp add"] + } + ] + }, { "pr": 0, "title": "File line range syntax support",