diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index b659054540c23..50dd9587454d8 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -20,8 +20,6 @@ import { DesktopPreviewWebviewConfigSchema, PreviewAnnotationSubmissionResultSchema, PreviewCaptureFailure, - DEFAULT_BROWSER_PROFILE_ID, - INCOGNITO_BROWSER_PROFILE_ID, PreviewForwardedShortcut, MAX_KEYBINDINGS_COUNT, } from "@t3tools/contracts"; @@ -31,6 +29,8 @@ import * as Option from "effect/Option"; import * as NodeURL from "node:url"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import { resolvePartitionScope } from "../../preview/BrowserProfileScope.ts"; +export { resolvePartitionScope } from "../../preview/BrowserProfileScope.ts"; import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts"; import * as PreviewManager from "../../preview/Manager.ts"; import { normalizeCaptureFailure } from "../../preview/captureErrors.ts"; @@ -262,32 +262,6 @@ const clearCache = DesktopIpc.makeIpcMethod({ }), }); -/** - * The default profile keeps the bare environment id it used before profiles - * existed, so upgrading does not strand anyone's existing logins in an orphaned - * partition. - */ -export function resolvePartitionScope( - environmentId: string, - profileId: string | undefined, -): { - readonly scope: string; - readonly persistent: boolean; - readonly namespace?: "profile"; -} { - if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) { - return { scope: environmentId, persistent: true }; - } - // JSON's tuple framing is injective for strings, including lone UTF-16 - // surrogates (which it escapes). URI encoding throws on those supported ids, - // while replacing them with U+FFFD would collapse distinct identities. - return { - scope: JSON.stringify([environmentId, profileId]), - persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID, - namespace: "profile" as const, - }; -} - /** * Clearing without a profile keeps the historical "everything" behaviour for * an explicit all-profiles action; naming a profile confines it to that diff --git a/apps/desktop/src/preview/BrowserProfileScope.ts b/apps/desktop/src/preview/BrowserProfileScope.ts new file mode 100644 index 0000000000000..2f7dbe4239337 --- /dev/null +++ b/apps/desktop/src/preview/BrowserProfileScope.ts @@ -0,0 +1,15 @@ +import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; + +export function resolvePartitionScope( + environmentId: string, + profileId: string | undefined, +): { readonly scope: string; readonly persistent: boolean; readonly namespace?: "profile" } { + // Preserve the legacy default partition so existing sign-ins survive profile support. + if (profileId === undefined || profileId === DEFAULT_BROWSER_PROFILE_ID) + return { scope: environmentId, persistent: true }; + return { + scope: JSON.stringify([environmentId, profileId]), + persistent: profileId !== INCOGNITO_BROWSER_PROFILE_ID, + namespace: "profile", + }; +} diff --git a/apps/desktop/src/preview/ClaudeConsoleBilling.test.ts b/apps/desktop/src/preview/ClaudeConsoleBilling.test.ts new file mode 100644 index 0000000000000..a3745726331b7 --- /dev/null +++ b/apps/desktop/src/preview/ClaudeConsoleBilling.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { readClaudeConsoleCredits } from "./ClaudeConsoleBilling.ts"; + +const organizationId = "11111111-1111-4111-8111-111111111111"; +const connection = { profileId: "default", organizationId }; + +describe("desktop Claude Console billing", () => { + it("reads only the bound API organization's credits through its existing session", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json([ + { uuid: "22222222-2222-4222-8222-222222222222", capabilities: ["chat"] }, + { uuid: organizationId, capabilities: ["api"] }, + ]), + ) + .mockResolvedValueOnce(Response.json({ currency: "USD", amount: 7310 })); + expect(await readClaudeConsoleCredits(connection, fetch)).toEqual({ + currency: "USD", + amount: 7310, + }); + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://platform.claude.com/api/organizations", + `https://platform.claude.com/api/organizations/${organizationId}/prepaid/credits`, + ]); + for (const [, options] of fetch.mock.calls) + expect(options).toMatchObject({ method: "GET", credentials: "include", redirect: "error" }); + }); + it("rejects subscription organizations before requesting a balance", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json([{ uuid: organizationId, capabilities: ["chat"] }])); + await expect(readClaudeConsoleCredits(connection, fetch)).rejects.toMatchObject({ + reason: "organizationUnavailable", + }); + expect(fetch).toHaveBeenCalledOnce(); + }); + it("reports a sign-in requirement without exposing private response contents", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ privateData: "invented confidential text" }, { status: 403 }), + ); + await expect(readClaudeConsoleCredits(connection, fetch)).rejects.toMatchObject({ + reason: "signInRequired", + }); + expect(fetch).toHaveBeenCalledOnce(); + }); + it("rejects incognito profiles and malformed organization bindings without network access", async () => { + const fetch = vi.fn(); + for (const input of [ + { ...connection, profileId: "incognito" }, + { ...connection, organizationId: "../unrelated" }, + ]) + await expect(readClaudeConsoleCredits(input, fetch)).rejects.toMatchObject({ + reason: "invalidBinding", + }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/preview/ClaudeConsoleBilling.ts b/apps/desktop/src/preview/ClaudeConsoleBilling.ts new file mode 100644 index 0000000000000..6a610c9f5c3f5 --- /dev/null +++ b/apps/desktop/src/preview/ClaudeConsoleBilling.ts @@ -0,0 +1,56 @@ +import { + ClaudeConsoleBillingReadError, + ClaudeConsoleBillingConnection, + INCOGNITO_BROWSER_PROFILE_ID, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const isConnection = Schema.is(ClaudeConsoleBillingConnection); +const isBillingReadError = Schema.is(ClaudeConsoleBillingReadError); +const ORIGIN = "https://platform.claude.com"; + +export async function readClaudeConsoleCredits( + connection: ClaudeConsoleBillingConnection, + fetch: Electron.Session["fetch"], +): Promise { + if (!isConnection(connection) || connection.profileId === INCOGNITO_BROWSER_PROFILE_ID) + throw new ClaudeConsoleBillingReadError({ reason: "invalidBinding" }); + try { + const options = { + method: "GET", + credentials: "include", + redirect: "error", + signal: AbortSignal.timeout(8_000), + } as const; + const organizationsResponse = await fetch(`${ORIGIN}/api/organizations`, options); + if ([401, 403].includes(organizationsResponse.status)) + throw new ClaudeConsoleBillingReadError({ reason: "signInRequired" }); + if (!organizationsResponse.ok) + throw new ClaudeConsoleBillingReadError({ reason: "requestFailed" }); + const organizations: unknown = await organizationsResponse.json(); + if ( + !Array.isArray(organizations) || + !organizations.some((entry: unknown) => { + if (entry === null || typeof entry !== "object") return false; + const organization = entry as { uuid?: unknown; capabilities?: unknown }; + return ( + organization.uuid === connection.organizationId && + Array.isArray(organization.capabilities) && + organization.capabilities.includes("api") + ); + }) + ) + throw new ClaudeConsoleBillingReadError({ reason: "organizationUnavailable" }); + const response = await fetch( + `${ORIGIN}/api/organizations/${encodeURIComponent(connection.organizationId)}/prepaid/credits`, + options, + ); + if ([401, 403].includes(response.status)) + throw new ClaudeConsoleBillingReadError({ reason: "signInRequired" }); + if (!response.ok) throw new ClaudeConsoleBillingReadError({ reason: "requestFailed" }); + return await response.json(); + } catch (error) { + if (isBillingReadError(error)) throw error; + throw new ClaudeConsoleBillingReadError({ reason: "requestFailed" }); + } +} diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 04dcd22cf3b2c..a4d1e3dbbacbc 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from "@effect/vitest"; import { DesktopBrowserEvent } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as NodeEvents from "node:events"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; +import * as BrowserSession from "./BrowserSession.ts"; const decodeEvent = Schema.decodeUnknownSync(Schema.fromJsonString(DesktopBrowserEvent)); const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -52,6 +54,47 @@ const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], coun ); describe("DesktopBrowserHost", () => { + it.effect("refreshes billing through the saved environment profile while no tab is open", () => + Effect.gen(function* () { + const organizationId = "11111111-1111-4111-8111-111111111111"; + const profiles: unknown[] = []; + const fetch: Electron.Session["fetch"] = async (url) => + String(url).endsWith("/api/organizations") + ? Response.json([{ uuid: organizationId, capabilities: ["api"] }]) + : Response.json({ currency: "USD", amount: 7310 }); + const host = yield* DesktopBrowserHost.make.pipe( + Effect.provide( + Layer.mock(BrowserSession.BrowserSession)({ + isPartition: () => false, + getSession: (scope, persistent, namespace) => { + profiles.push({ scope, persistent, namespace }); + return Effect.succeed({ fetch } as Electron.Session); + }, + }), + ), + ); + const reader = yield* Effect.forkScoped(takeEvents(host, 1), { startImmediately: true }); + yield* host.handleCommandLine( + encodeJson({ + type: "claudeBilling", + requestId: "synthetic-request", + environmentId: "synthetic-environment", + connection: { profileId: "default", organizationId }, + }), + ); + expect(yield* Fiber.join(reader)).toEqual([ + { + type: "claudeBilling", + requestId: "synthetic-request", + result: { _tag: "Success", credits: { currency: "USD", amount: 7310 } }, + }, + ]); + expect(profiles).toEqual([ + { scope: "synthetic-environment", persistent: true, namespace: undefined }, + ]); + }).pipe(Effect.scoped), + ); + it.effect("announces tabs already attached to a backend that starts later", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index cef42d4f35fc3..f69d29ffba596 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -6,6 +6,7 @@ import { DesktopBrowserCommand, DesktopBrowserEvent, + ClaudeConsoleBillingReadError, type DesktopBrowserEvent as DesktopBrowserEventType, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -19,9 +20,13 @@ import * as Stream from "effect/Stream"; import { createCdpRelayConnection, type CdpRelayConnection } from "./CdpRelay.ts"; import { sendPageCommand } from "./GuestInput.ts"; +import * as BrowserSession from "./BrowserSession.ts"; +import { resolvePartitionScope } from "./BrowserProfileScope.ts"; +import { readClaudeConsoleCredits } from "./ClaudeConsoleBilling.ts"; const encodeEvent = Schema.encodeSync(Schema.fromJsonString(DesktopBrowserEvent)); const decodeCommand = Schema.decodeUnknownOption(Schema.fromJsonString(DesktopBrowserCommand)); +const isBillingReadError = Schema.is(ClaudeConsoleBillingReadError); const lineEncoder = new TextEncoder(); const AGENT_INPUT_COMMAND = /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; @@ -73,6 +78,7 @@ export class DesktopBrowserHost extends Context.Service< >()("@t3tools/desktop/preview/DesktopBrowserHost") {} export const make = Effect.gen(function* () { + const browserSessions = yield* Effect.serviceOption(BrowserSession.BrowserSession); const outbox = yield* PubSub.unbounded(); const pointers = yield* PubSub.sliding<{ readonly key: DesktopBrowserTabKey; @@ -165,6 +171,51 @@ export const make = Effect.gen(function* () { Effect.sync(() => { const command = decodeCommand(line); if (Option.isNone(command)) return; + if (command.value.type === "claudeBilling") { + const { requestId, environmentId, connection } = command.value; + runFork( + Effect.gen(function* () { + if (Option.isNone(browserSessions)) + return yield* new ClaudeConsoleBillingReadError({ reason: "requestFailed" }); + const { scope, persistent, namespace } = resolvePartitionScope( + environmentId, + connection.profileId, + ); + if (!persistent) + return yield* new ClaudeConsoleBillingReadError({ reason: "invalidBinding" }); + const session = yield* browserSessions.value + .getSession(scope, persistent, namespace) + .pipe( + Effect.mapError( + () => new ClaudeConsoleBillingReadError({ reason: "requestFailed" }), + ), + ); + return yield* Effect.tryPromise({ + try: () => readClaudeConsoleCredits(connection, session.fetch.bind(session)), + catch: (error) => + isBillingReadError(error) + ? error + : new ClaudeConsoleBillingReadError({ reason: "requestFailed" }), + }); + }).pipe( + Effect.matchEffect({ + onSuccess: (credits) => + PubSub.publish(outbox, { + type: "claudeBilling", + requestId, + result: { _tag: "Success", credits }, + }), + onFailure: (error) => + PubSub.publish(outbox, { + type: "claudeBilling", + requestId, + result: { _tag: "Failure", error }, + }), + }), + ), + ); + return; + } const tab = tabs.get(keyOf(command.value)); if (!tab) return; if (command.value.type === "pointer") { diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index 431cf61b1ec62..4466735defae1 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -56,6 +56,7 @@ export function ComposerModelControl(props: { ) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index c7064e522a995..01dcd87062d5b 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -7,6 +7,7 @@ import { providerInstanceInitials, } from "@t3tools/client-runtime/state/provider-instance-display"; import { useId, useState } from "react"; +import { resolveProviderIcon } from "@t3tools/provider-core/client"; import { resolveOfficialAcpRegistryIconUrl } from "@t3tools/contracts"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { AppText as Text } from "./ui/AppText"; @@ -14,6 +15,7 @@ import { getProviderClient } from "../lib/providerClients"; type ProviderIconProps = { readonly provider: string | null | undefined; + readonly instanceId?: string | null | undefined; readonly iconUrl?: string | null | undefined; readonly size?: number; }; @@ -98,7 +100,7 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - const packageIcon = getProviderClient(props.provider)?.icon; + const packageIcon = resolveProviderIcon(getProviderClient(props.provider), props.instanceId); if (packageIcon) { const themeFill = (fill: { readonly light: string; readonly dark: string }) => isDarkMode ? fill.dark : fill.light; @@ -154,6 +156,7 @@ export function ProviderIcon(props: ProviderIconProps) { export function ProviderInstanceIcon(props: { readonly iconUrl?: string | null; readonly provider: string | null | undefined; + readonly instanceId?: string | null | undefined; readonly size?: number; readonly displayName: string; readonly accentColor?: string; @@ -164,7 +167,12 @@ export function ProviderInstanceIcon(props: { return ( - + {props.showBadge ? ( // A wide right-aligned box lets the badge fit its initials. Alone, an diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index 357a81110380f..cdaa1c50824b9 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -328,7 +328,11 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm - + {provider.displayName ?? provider.driver} diff --git a/apps/mobile/src/features/threads/PrivateControls.tsx b/apps/mobile/src/features/threads/PrivateControls.tsx new file mode 100644 index 0000000000000..89afd4dd7b843 --- /dev/null +++ b/apps/mobile/src/features/threads/PrivateControls.tsx @@ -0,0 +1,178 @@ +import type { + EnvironmentId, + PrivateHelperPacket, + PrivateHelpersPolicy, + ThreadId, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; +import { useState } from "react"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { AppText as Text } from "../../components/ui/AppText"; +import { Button } from "../../components/ui/Button"; +import { serverEnvironment } from "../../state/server"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; + +export function PrivateControls(props: { + environmentId: EnvironmentId; + threadId: ThreadId; + policy: PrivateHelpersPolicy; + canOperate: boolean; +}) { + const [open, setOpen] = useState(false); + const [packets, setPackets] = useState([]); + const [reviewed, setReviewed] = useState(null); + const [busy, setBusy] = useState(false); + const load = useAtomCommand(serverEnvironment.getPrivateHelperState); + const decide = useAtomCommand(serverEnvironment.decidePrivateHelper); + const update = useAtomCommand(threadEnvironment.updateMetadata); + const refresh = async () => { + const result = await load({ + environmentId: props.environmentId, + input: { threadId: props.threadId }, + }); + if (AsyncResult.isSuccess(result)) setPackets(result.value.packets); + }; + const decision = async (packet: PrivateHelperPacket, value: "approve" | "block") => { + setBusy(true); + try { + const result = await decide({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + packetId: packet.id, + digest: packet.digest, + decision: value, + }, + }); + if (AsyncResult.isSuccess(result)) + setPackets((current) => + current.map((entry) => (entry.id === packet.id ? result.value : entry)), + ); + setReviewed(null); + } catch (error) { + setBusy(false); + throw error; + } + setBusy(false); + }; + return ( + <> + { + setOpen(true); + void refresh(); + }} + className="min-h-11 self-start justify-center px-3" + > + + {props.policy === "disabled" ? "Private" : "Private + helpers"} + + + setOpen(false)} + presentationStyle="pageSheet" + animationType="slide" + > + + + Private controls + + Full conversation context uses Tinfoil. External helpers receive only the exact packet + you review and approve. Auto action permissions stay separate. + + + + + {packets.length === 0 ? ( +

+ Your private coordinator can prepare a self-contained helper packet. Pending reviews + expire after 15 minutes or a server restart. +

+ ) : null} + {packets.map((packet) => ( +
+
+ {packet.modelSelection.instanceId} ·{" "} + {packet.modelSelection.model} +

{packet.status}

+
+

{packet.task.objective}

+
{ + if (event.currentTarget.open) setReviewed(packet.id); + }} + > + + Inspect exact outbound packet + +
+                    {packet.prompt}
+                  
+
+ {packet.status === "awaiting-review" ? ( +
+ + +
+ ) : null} + {packet.artifact === undefined ? null : ( +
+ + Inspect returned artifact + +
+                      {packet.artifact}
+                    
+

+ The private coordinator reviews this artifact before applying or running it. +

+
+ )} +
+ ))} + + + + + ); +} diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index ec11b890ddda4..920ebe4c04008 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -7,6 +7,7 @@ import { } from "@t3tools/client-runtime/state/provider-instance-display"; import { ProviderDriverKind } from "@t3tools/contracts"; +import { resolveProviderIcon } from "@t3tools/provider-core/client"; import { AntigravityIcon, ClaudeAI, Icon, OpenAI } from "../Icons"; import { cn } from "~/lib/utils"; @@ -59,6 +60,7 @@ export function resolveProviderInstanceAcpRegistryIconUrl(input: { export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { driverKind: ProviderDriverKind; + instanceId?: string | undefined; displayName: string; accentColor?: string | undefined; acpRegistryAgentId?: string | undefined; @@ -73,7 +75,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { indicatorBackground?: string; }) { const Icon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; - const packageIcon = providerClients.get(props.driverKind)?.icon; + const packageIcon = resolveProviderIcon(providerClients.get(props.driverKind), props.instanceId); const indicatorBackground = props.indicatorBackground ?? "var(--card)"; const accentColor = normalizeProviderAccentColor(props.accentColor); const accentStyle = accentColor diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index c169ac10e6b1b..1e8c384364536 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -219,6 +219,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { selection.entry ? ( { + // The shared clock subscribes during mount, so timers start before rendering. + using _timers = fakeTimers({ now: 1000 }); + const item = { + status: "running", + updatedAt: DateTime.makeUnsafe(1000), + retry: { attempt: 1, maxAttempts: null, retryDelayMs: 5000 }, + failure: { + class: "provider_error", + code: "provider.overloaded", + message: "Selected model at capacity", + retryable: true, + }, + } as const; + // Standalone status row has no route-dependent behavior. + await using screen = await render(, { router: false }); + await expect.element(screen.getByText("Retrying in 5s · attempt 1")).toBeVisible(); + await expect.element(screen.getByText("Selected model at capacity")).toBeVisible(); + await act(() => vi.advanceTimersByTimeAsync(3000)); + await expect.element(screen.getByText("Retrying in 2s · attempt 1")).toBeVisible(); + await act(() => vi.advanceTimersByTimeAsync(2000)); + await expect.element(screen.getByText("Retrying now · attempt 1")).toBeVisible(); + await screen.rerender( + , + ); + await expect.element(screen.getByText("Retrying in 10s · attempt 2")).toBeVisible(); +}); diff --git a/apps/web/src/components/chat/ProviderRetryStatus.tsx b/apps/web/src/components/chat/ProviderRetryStatus.tsx new file mode 100644 index 0000000000000..1eddb47f49f13 --- /dev/null +++ b/apps/web/src/components/chat/ProviderRetryStatus.tsx @@ -0,0 +1,33 @@ +import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; +import { providerRetryCountdown } from "@t3tools/shared/orchestrationV2ThreadError"; +import { RotateCcwIcon } from "lucide-react"; +import { useNowSecond } from "../../hooks/useNowSecond"; +import { WorkLogRow } from "./WorkLog"; + +export function ProviderRetryStatus({ + item, +}: { + item: Pick< + Extract, + "status" | "updatedAt" | "retry" | "failure" + >; +}) { + const label = providerRetryCountdown(item, useNowSecond()); + return ( + } + wrapLabel + label={ + + {label} + {item.failure.message} + + } + > +

+ Continuing automatically. Use Stop to cancel. +

+
+ ); +} diff --git a/apps/web/src/components/chat/ProviderSubagentBar.tsx b/apps/web/src/components/chat/ProviderSubagentBar.tsx index da3777dba1f48..33c6d8fc13536 100644 --- a/apps/web/src/components/chat/ProviderSubagentBar.tsx +++ b/apps/web/src/components/chat/ProviderSubagentBar.tsx @@ -52,6 +52,7 @@ export function ProviderSubagentBar(props: { {props.provider ? ( ; if (failureItem?.type === "error" && failureItem.status === "failed") { const warning = failureItem.failure.class === "usage_limit"; const resetAt = failureItem.failure.resetAt; diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index 07d6164817c19..774534b9fcc23 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -1,3 +1,4 @@ +import { ProviderRetryStatus } from "./ProviderRetryStatus"; import { WorkLogButton, WorkLogList, WorkLogRow } from "./WorkLog"; import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; import { @@ -489,6 +490,13 @@ export function LiveWorkEntryTimelineRow({ }) { const ctx = use(TimelineRowCtx); const threadTarget = useThreadReadTarget(row.entry, ctx.activeThreadEnvironmentId); + const retryItem = row.entry.projectedItem?.item; + if ( + retryItem?.type === "error" && + retryItem.status === "running" && + retryItem.retry !== undefined + ) + return ; const questionHeading = row.entry.questionAnswer ? getQuestionTextPreview(row.entry.questionAnswer) : ""; diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 06e6f8fc3b3ac..765b0aa3dd0e2 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -257,12 +257,14 @@ export function SubagentElapsed({ agent }: { agent: Parameters providers?.find((provider) => provider.instanceId === providerInstanceId)?.iconUrl, ); - return ; + return ( + + ); } const NOTIFICATION_OUTCOME_STATUS: Record< @@ -660,6 +670,7 @@ function HandoffEndpoint(props: { > { }, }; }; +const completed = () => { + const thread = running(); + return { + ...thread, + runtime: null, + latestRun: { ...thread.latestRun, status: "completed" as const, completedAt: now }, + }; +}; describe("Home activity", () => { - it("shows active and unsettled work while excluding settled and archived history", () => { + it("lets completed threads leave Working while persistent commands keep running", () => { + const thread = { + ...completed(), + runtime: { ...running().runtime, status: "completed" as const, activeRunId: null }, + pendingBackgroundTasks: [{ kind: "command" as const, taskId: "preview-server" }], + }; + expect(buildHomeActivity([thread], now)[0]).toMatchObject({ + kind: "unread", + label: "Unread", + startedAt: start, + completedAt: now, + }); + expect(buildHomeActivity([{ ...thread, lastVisitedAt: now }], now)[0]).toMatchObject({ + kind: "done", + label: "Done", + completedAt: now, + }); + const parent = completed(); + const child = { + ...thread, + id: ThreadId.make("child"), + lineage: { + rootThreadId: parent.id, + parentThreadId: parent.id, + relationshipToParent: "subagent" as const, + }, + }; + expect(buildHomeActivity([parent, child], now)[0]?.kind).toBe("unread"); + }); + it.each(["subagent", "monitor", "background_task"] as const)( + "keeps completion-holding %s work in Working", + (kind) => { + const thread = { + ...completed(), + pendingBackgroundTasks: [ + { kind: "command" as const, taskId: "preview-server" }, + { kind, taskId: "active-work" }, + ], + }; + expect(buildHomeActivity([thread], now)[0]).toMatchObject({ + kind: "working", + completedAt: null, + }); + }, + ); + it("separates unopened completions from read, resting threads", () => { + const unread = completed(); + const done = { + ...completed(), + id: ThreadId.make("done"), + lastVisitedAt: now, + }; + const rows = buildHomeActivity([done, unread], now); + expect(rows.map(({ kind, unread, label }) => ({ kind, unread, label }))).toEqual([ + { kind: "unread", unread: true, label: "Unread" }, + { kind: "done", unread: false, label: "Done" }, + ]); + }); + it("moves completed work between unread and done as the visit watermark changes", () => { + const thread = completed(); + const read = { ...thread, lastVisitedAt: now }; + expect(buildHomeActivity([read], now)[0]?.kind).toBe("done"); + const markedUnread = { ...read, lastVisitedAt: start }; + expect(buildHomeActivity([markedUnread], now)[0]?.kind).toBe("unread"); + expect(buildHomeActivity([{ ...markedUnread, lastVisitedAt: now }], now)[0]?.kind).toBe("done"); + }); + it("honors server visits over local watermarks and scopes legacy visits by environment", () => { + const { lastVisitedAt: _lastVisitedAt, ...thread } = completed(); + const visits = { [`${thread.environmentId}:${thread.id}`]: now }; + expect(buildHomeActivity([thread], now, visits)[0]?.kind).toBe("done"); + const remote = { ...thread, environmentId: EnvironmentId.make("remote") }; + expect(buildHomeActivity([remote], now, visits)[0]?.kind).toBe("unread"); + expect(buildHomeActivity([{ ...thread, lastVisitedAt: start }], now, visits)[0]?.kind).toBe( + "unread", + ); + }); + it("keeps active work and attention ahead of unread completions", () => { + const working = { ...running(), id: ThreadId.make("working") }; + const attention = { ...completed(), id: ThreadId.make("attention"), hasPendingApprovals: true }; + expect( + buildHomeActivity([completed(), working, attention], now).map((row) => row.kind), + ).toEqual(["attention", "working", "unread"]); + }); + it("hides snoozed and deleted completions while keeping active work visible", () => { + const snoozed = { + ...completed(), + snoozedAt: now, + snoozedUntil: "2026-10-24T12:00:00.000Z", + }; + const deleted = { ...completed(), id: ThreadId.make("deleted"), deletedAt: now }; + expect(buildHomeActivity([snoozed, deleted], now)).toEqual([]); + expect( + buildHomeActivity([{ ...running(), snoozedUntil: snoozed.snoozedUntil }], now)[0]?.kind, + ).toBe("working"); + }); + it("leaves drafts and invalid completion timestamps in done without an unread marker", () => { + const thread = completed(); + const invalid = { ...thread, latestRun: { ...thread.latestRun, completedAt: "invalid" } }; + expect(buildHomeActivity([invalid], now)[0]).toMatchObject({ kind: "done", unread: false }); + expect(buildHomeActivity([root()], now)[0]).toMatchObject({ + kind: "done", + unread: false, + label: "Ready", + }); + }); + it("shows working threads while excluding settled and archived history", () => { const working = running(); const settled = { ...root(), @@ -96,13 +209,13 @@ describe("Home activity", () => { const rows = buildHomeActivity([done, parent, child], now); expect(rows[0]).toMatchObject({ kind: "attention", label: "Approval needed" }); expect(rows[1]).toMatchObject({ - kind: "review", - label: "Done", + kind: "unread", + label: "Unread", startedAt: start, completedAt: now, }); }); - it("honors explicitly settled failures and leaves untimed drafts ready", () => { + it("hides explicitly settled failures and leaves untimed drafts ready", () => { const failed = { ...running(), runtime: { ...running().runtime, status: "failed" as const }, @@ -111,7 +224,31 @@ describe("Home activity", () => { const draft = { ...root(), id: ThreadId.make("draft") }; const rows = buildHomeActivity([failed, draft], now); expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ label: "Ready", startedAt: null }); + expect(rows.find((row) => row.thread.id === draft.id)).toMatchObject({ + label: "Ready", + startedAt: null, + }); + }); + it("hides settled unread, read, and active threads together with their agents", () => { + const unread = { ...completed(), settledOverride: "settled" as const }; + const read = { ...unread, id: ThreadId.make("read"), lastVisitedAt: now }; + const working = { + ...running(), + id: ThreadId.make("working"), + settledOverride: "settled" as const, + }; + const child = { + ...running(), + id: ThreadId.make("child"), + hasPendingApprovals: true, + lineage: { + rootThreadId: unread.id, + parentThreadId: unread.id, + relationshipToParent: "subagent" as const, + }, + }; + expect(buildHomeActivity([unread, read, working, child], now)).toEqual([]); + expect(buildHomeActivity([{ ...read, settledOverride: "active" }], now)[0]?.kind).toBe("done"); }); it("preserves orphaned agents and tolerates cyclic lineage", () => { const a = { diff --git a/apps/web/src/components/home/HomeActivity.logic.ts b/apps/web/src/components/home/HomeActivity.logic.ts index c741e2c1b756f..8d2c3dd8865e9 100644 --- a/apps/web/src/components/home/HomeActivity.logic.ts +++ b/apps/web/src/components/home/HomeActivity.logic.ts @@ -5,15 +5,21 @@ import { type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/models"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; -import { resolveSidebarThreadStatus } from "../Sidebar.logic"; +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; +import { + hasUnseenCompletion, + resolveSidebarThreadStatus, + resolveThreadLastVisitedAt, +} from "../Sidebar.logic"; type Thread = EnvironmentThreadShell; -export type HomeActivityKind = "attention" | "working" | "review"; +export type HomeActivityKind = "attention" | "working" | "unread" | "done"; export interface HomeActivity { readonly key: string; readonly thread: Thread; readonly members: readonly Thread[]; readonly kind: HomeActivityKind; + readonly unread: boolean; readonly label: string; readonly startedAt: string | null; readonly completedAt: string | null; @@ -24,9 +30,14 @@ const keyOf = (thread: Thread) => scopedThreadKey(scopeThreadRef(thread.environm const needsAttention = (thread: Thread) => ["approval", "input", "failed", "limited"].includes(resolveSidebarThreadStatus(thread)); const doingWork = (thread: Thread) => - threadRuntimeIsActive(thread.runtime) || thread.pendingBackgroundTasks.length > 0; + threadRuntimeIsActive(thread.runtime) || + backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks); -export function buildHomeActivity(threads: readonly Thread[], now: string): HomeActivity[] { +export function buildHomeActivity( + threads: readonly Thread[], + now: string, + localVisits: Readonly> = {}, +): HomeActivity[] { const visible = threads.filter( (thread) => thread.archivedAt === null && thread.deletedAt === null, ); @@ -58,19 +69,25 @@ export function buildHomeActivity(threads: readonly Thread[], now: string): Home } const rows: HomeActivity[] = []; for (const [key, { thread, members }] of groups) { + if (thread.settledOverride === "settled") continue; const active = members.filter( (member) => doingWork(member) || (member.settledOverride !== "settled" && needsAttention(member)), ); - if (thread.settledOverride === "settled" && active.length === 0) continue; if (effectiveSnoozed(thread, { now }) && active.length === 0) continue; const attention = active.find(needsAttention); const working = active.filter(doingWork); + const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localVisits[key]); + const unread = + Number.isFinite(Date.parse(thread.latestRun?.completedAt ?? "")) && + (!lastVisitedAt || hasUnseenCompletion({ ...thread, lastVisitedAt })); const kind: HomeActivityKind = attention ? "attention" : working.length > 0 ? "working" - : "review"; + : unread + ? "unread" + : "done"; const attentionStatus = attention ? resolveSidebarThreadStatus(attention) : null; const label = attentionStatus === "approval" @@ -85,11 +102,13 @@ export function buildHomeActivity(threads: readonly Thread[], now: string): Home ? thread.blockedOnThreadWait ? "Waiting for agents" : "Working" - : thread.hasActionableProposedPlan - ? "Plan ready" - : thread.latestRun?.completedAt - ? "Done" - : "Ready"; + : kind === "unread" + ? "Unread" + : thread.hasActionableProposedPlan + ? "Plan ready" + : thread.latestRun?.completedAt + ? "Done" + : "Ready"; const starts = ( working.length === 0 ? [ @@ -125,6 +144,7 @@ export function buildHomeActivity(threads: readonly Thread[], now: string): Home thread, members, kind, + unread, label, startedAt, completedAt: @@ -133,7 +153,7 @@ export function buildHomeActivity(threads: readonly Thread[], now: string): Home nativeAgentCount, }); } - const rank: Record = { attention: 0, working: 1, review: 2 }; + const rank: Record = { attention: 0, working: 1, unread: 2, done: 3 }; return rows.sort( (a, b) => rank[a.kind] - rank[b.kind] || diff --git a/apps/web/src/components/home/HomeActivityOverview.browser.test.tsx b/apps/web/src/components/home/HomeActivityOverview.browser.test.tsx index d928635e25b0f..f93e19f44adbb 100644 --- a/apps/web/src/components/home/HomeActivityOverview.browser.test.tsx +++ b/apps/web/src/components/home/HomeActivityOverview.browser.test.tsx @@ -123,3 +123,35 @@ it("opens the thread menu on a double-click without navigating to or reading the expect(screen.router?.state.location.pathname).toBe("/home"); await act(() => dismiss()); }); + +it("moves completed work to Unread and Done while its preview server keeps running", async () => { + const { working } = setup(); + await using screen = await render(, { router: "/home" }); + await expect.element(screen.getByRole("region", { name: "Working", exact: true })).toBeVisible(); + const completed = { + ...working, + runtime: { ...working.runtime!, status: "completed" as const, activeRunId: null }, + latestRun: { + ...working.latestRun!, + status: "completed" as const, + completedAt: "2026-10-23T12:13:00Z", + }, + pendingBackgroundTasks: [{ kind: "command" as const, taskId: "preview-server" }], + }; + await act(() => { + state.threads = [completed]; + for (const listener of state.listeners) listener(); + }); + await expect + .element(screen.getByRole("region", { name: "Working", exact: true })) + .not.toBeInTheDocument(); + await expect.element(screen.getByRole("region", { name: "Unread", exact: true })).toBeVisible(); + await act(() => { + state.threads = [{ ...completed, lastVisitedAt: "2026-10-23T12:14:00Z" }]; + for (const listener of state.listeners) listener(); + }); + await expect + .element(screen.getByRole("region", { name: "Unread", exact: true })) + .not.toBeInTheDocument(); + await expect.element(screen.getByRole("region", { name: "Done", exact: true })).toBeVisible(); +}); diff --git a/apps/web/src/components/home/HomeActivityOverview.tsx b/apps/web/src/components/home/HomeActivityOverview.tsx index 04554326f1129..390adc15098a4 100644 --- a/apps/web/src/components/home/HomeActivityOverview.tsx +++ b/apps/web/src/components/home/HomeActivityOverview.tsx @@ -6,6 +6,7 @@ import { CircleAlertIcon, LayersIcon, EllipsisIcon, + MailIcon, } from "lucide-react"; import { useDeferredValue, @@ -30,7 +31,6 @@ import { useEnvironments, useEnvironmentProviders } from "../../state/environmen import { useNowMinute } from "../../hooks/useNowMinute"; import { useUiStateStore } from "../../uiStateStore"; import { buildThreadRouteParams } from "../../threadRoutes"; -import { hasUnseenCompletion, resolveThreadLastVisitedAt } from "../Sidebar.logic"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { AgentElapsed } from "../chat/AgentElapsed"; import { Button } from "../ui/button"; @@ -49,12 +49,14 @@ import { cn } from "../../lib/utils"; const GROUPS = [ { kind: "attention", title: "Needs attention", hint: "Your next move" }, { kind: "working", title: "Working", hint: "Agents in motion" }, - { kind: "review", title: "Ready to revisit", hint: "Unsettled work" }, + { kind: "unread", title: "Unread", hint: "Completed and waiting for you" }, + { kind: "done", title: "Done", hint: "Read and resting" }, ] as const; const STATUS_COLOR: Record = { attention: "text-warning", working: "text-info", - review: "text-success", + unread: "text-info", + done: "text-muted-foreground", }; export function HomeActivity() { @@ -65,7 +67,7 @@ export function HomeActivity() { const bootstrapped = useAllEnvironmentShellsBootstrapped(); const localVisits = useUiStateStore((state) => state.threadLastVisitedAtById); const now = useNowMinute(); - const liveRows = buildHomeActivity(threads, `${now}:00.000Z`); + const liveRows = buildHomeActivity(threads, `${now}:00.000Z`, localVisits); const [held, setHeld] = useState<{ rows: Activity[]; at: string } | null>(null); const rows = held?.rows ?? liveRows; const [renaming, setRenaming] = useState(null); @@ -144,12 +146,8 @@ export function HomeActivity() { >
-

- Workspace activity -

-

- Your agents, at a glance. -

+

Workspace activity

+

Your agents at a glance

Your agents work here. Open a thread when it needs your attention.

@@ -158,15 +156,10 @@ export function HomeActivity() { {connected} {connected === 1 ? "environment" : "environments"} connected

-
+
{GROUPS.map((group) => (
-

+

{rows.filter((row) => row.kind === group.kind).length}

{group.title}

@@ -188,8 +181,7 @@ export function HomeActivity() { A quiet workspace - Unsettled threads appear here as you work. Start or reopen a thread from the sidebar; - your saved drafts stay ready. + Your threads appear here as you work. Start a thread from the sidebar to get going. @@ -208,10 +200,6 @@ export function HomeActivity() { const environment = environments.find( (entry) => entry.environmentId === row.thread.environmentId, ); - const visitedAt = resolveThreadLastVisitedAt( - row.thread.lastVisitedAt, - localVisits[row.key], - ); return ( ); })} @@ -265,7 +252,6 @@ function ActivityRow({ project, environment, online, - unread, pausedAt, onOpen, onMenu, @@ -275,7 +261,6 @@ function ActivityRow({ readonly project: string; readonly environment: string; readonly online: boolean; - readonly unread: boolean; readonly pausedAt: string | null; readonly onOpen: (event: MouseEvent) => void; readonly onMenu: (position: { x: number; y: number }, at: string) => void; @@ -299,7 +284,13 @@ function ActivityRow({ .replace(/\s+/g, " ") .slice(0, 180); const Icon = - row.kind === "attention" ? CircleAlertIcon : row.kind === "working" ? ActivityIcon : CheckIcon; + row.kind === "attention" + ? CircleAlertIcon + : row.kind === "working" + ? ActivityIcon + : row.kind === "unread" + ? MailIcon + : CheckIcon; return (
  • {row.thread.title} - {unread && ( + {row.unread && ( {modelRow} + {isProjectScope && + representative?.serverConfig?.environment.capabilities.privateWorkflow === true && + settings.privateModelRoute !== null ? ( + + updateSettings( + checked + ? { + modelPrivacy: "private", + defaultModelSelection: settings.privateModelRoute!.modelSelection, + } + : { modelPrivacy: "standard" }, + ) + } + aria-label="Require private workflow for this project" + /> + } + /> + ) : null} {workspaceRow} ) : category === "general" ? ( diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index bd86586710461..d087df112d905 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -571,6 +571,7 @@ interface ProviderInstanceCardProps { readonly onSelect?: (() => void) | undefined; readonly readOnly?: boolean | undefined; readonly canWriteSettings?: boolean; + readonly supportsTinfoilBilling?: boolean; readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; readonly onDelete?: (() => void) | undefined; readonly headerAction?: ReactNode | undefined; @@ -622,6 +623,7 @@ export function ProviderInstanceCard({ onSelect, readOnly = false, canWriteSettings = true, + supportsTinfoilBilling = false, onUpdate, onDelete, headerAction, @@ -803,6 +805,7 @@ export function ProviderInstanceCard({ const titleIconNode = ( ); + const consoleBilling = + instance.config !== null && typeof instance.config === "object" + ? (instance.config as Record).consoleBilling + : undefined; + const billingConnected = + instance.driver === "claudeAgent" && + typeof consoleBilling === "string" && + consoleBilling.length > 0; const runtimeFields = driverOption ? ( - + <> + + {billingConnected ? ( + + + + ) : null} + ) : ( { + it("offers Tinfoil billing secrets only to a compatible backend and Tinfoil connection", () => { + const opencode = providerClients.get(ProviderDriverKind.make("opencode"))!; + const tinfoil = { customModels: ["tinfoil/llama3-3-70b"] }; + expect(deriveProviderSettingsFields(opencode, tinfoil).map((field) => field.key)).not.toContain( + "tinfoilAdminKey", + ); + expect( + deriveProviderSettingsFields(opencode, tinfoil, true).find( + (field) => field.key === "tinfoilAdminKey", + ), + ).toMatchObject({ secret: true, control: "password" }); + expect( + deriveProviderSettingsFields(opencode, { customModels: ["opencode/gpt-5"] }, true).map( + (field) => field.key, + ), + ).not.toContain("tinfoilAdminKey"); + }); + it("keeps every masked field as a stored secret", () => { const settingsSchema = makeProviderSettingsSchema({ token: TrimmedString.pipe( diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index e1f41a5568a9a..29a0bcbb9f60c 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -19,12 +19,18 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; +import { + OpenCodeSettings, + resolveOpenCodeUsageProvider, +} from "@t3tools/provider-opencode/settings"; export type SettingsFormDefinition = Pick & { readonly driverKind?: ProviderClientDefinition["driverKind"]; }; import { SettingsRow } from "./settingsLayout"; +const decodeOpenCodeSettings = Schema.decodeUnknownOption(OpenCodeSettings); + export interface ProviderSettingsFieldModel { readonly key: string; readonly control: ProviderSettingsFormControl; @@ -85,6 +91,7 @@ function readFieldBooleanDefault( export function deriveProviderSettingsFields( definition: SettingsFormDefinition, value?: unknown, + supportsTinfoilBilling = false, ): ReadonlyArray { const isLocalAcp = definition.driverKind === "acpRegistry" && @@ -107,6 +114,15 @@ export function deriveProviderSettingsFields( const fieldSchema = definition.settingsSchema.fields[key]!; const formAnnotation = readProviderSettingsFormAnnotation(fieldSchema); if (formAnnotation.hidden) return []; + if (key === "tinfoilAdminKey" && definition.driverKind === "opencode") { + const settings = decodeOpenCodeSettings(value ?? {}); + if ( + !supportsTinfoilBilling || + Option.isNone(settings) || + resolveOpenCodeUsageProvider(settings.value) !== "tinfoil" + ) + return []; + } if (isLocalAcp && key !== "source" && key !== "commandPath") return []; const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); @@ -295,6 +311,7 @@ interface ProviderSettingsFormProps { readonly idPrefix: string; readonly variant: "card" | "dialog" | "settings"; readonly onChange: (nextConfig: Record | undefined) => void; + readonly supportsTinfoilBilling?: boolean; } function ProviderSettingsSelect({ @@ -565,8 +582,9 @@ export function ProviderSettingsForm({ idPrefix, variant, onChange, + supportsTinfoilBilling = false, }: ProviderSettingsFormProps) { - const fields = deriveProviderSettingsFields(definition, value); + const fields = deriveProviderSettingsFields(definition, value, supportsTinfoilBilling); const isLocalAcp = definition.driverKind === "acpRegistry" && readProviderConfigString(value, "source") === "local"; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 78e7ea1435837..355e32fb366e4 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -1277,6 +1277,10 @@ function ProviderInstanceRow({ readonly actions: ProviderInstanceRowActions; }) { const allProjects = useProjects(); + const supportsTinfoilBilling = useAtomValue( + serverEnvironment.configValueAtom(environmentId), + (config) => config?.environment.capabilities.tinfoilBilling === true, + ); const acpProjects = mode === "editor" && liveProvider?.driver === "acpRegistry" ? allProjects.filter((project) => project.environmentId === environmentId) @@ -1315,6 +1319,7 @@ function ProviderInstanceRow({ return ( ; +const NO_HISTORY: readonly UsageAccountHistoryEnvironment[] = []; + +function quotaLabel(account: UsageAccount, window: ReturnType[number]) { + return (usageWindowDetails(account.driver, window.id)?.label ?? window.label) + .replace(/\s*·\s*Reported quota$/, "") + .replace( + /^gemini-(.+?)(?:-tiered)?$/, + (_, model: string) => + `Gemini ${model.replaceAll("-", " ").replace(/\b(flash|pro|lite)\b/g, (word) => word[0]!.toUpperCase() + word.slice(1))}`, + ); +} + +function AccountRow({ + account, + now, + density, + onFocus, + historyEnvironments, + showEnvironment, +}: { + readonly account: UsageAccount; + readonly now: number; + readonly density: Density; + readonly onFocus: () => void; + readonly historyEnvironments: readonly UsageAccountHistoryEnvironment[]; + readonly showEnvironment: boolean; +}) { + const { limits } = account; + const [expanded, setExpanded] = useState(false); + const detailsId = useId(); + const driver = providerClients.get(account.driver); + const color = barColor(account.driver); + const windows = usageAccountWindows(account, now); + const history = usageAccountHistory(account, historyEnvironments); + const isApi = account.category === "API key"; + const external = limits?.externalUsage; + const credits = limits?.creditBalance; + const prepaidBalance = limits?.prepaidBalance; + const creditPercent = prepaidCreditPercent(prepaidBalance); + const creditValue = credits?.unlimited + ? "Unlimited" + : credits?.balance !== undefined + ? Number(credits.balance).toLocaleString(undefined, { maximumFractionDigits: 2 }) + : credits?.hasCredits + ? "—" + : "0"; + const summaryLimit = credits || isApi ? 2 : 3; + const summaryWindows = + windows.length <= summaryLimit + ? windows + : [...windows] + .sort( + (left, right) => + Number(right.kind === "monthly") - Number(left.kind === "monthly") || + remainingPercent(left) - remainingPercent(right), + ) + .slice(0, summaryLimit); + const exhaustedCount = windows.filter((window) => remainingPercent(window) === 0).length; + const historyNotice = + history.state === "loading" + ? "Loading usage…" + : history.state === "shared" + ? "Shared history · see Cost & Tokens" + : "No linked usage yet"; + const CategoryIcon = isApi + ? KeyRoundIcon + : account.category === "Subscription" + ? ShieldCheckIcon + : LayersIcon; + const providerLabel = + account.driver === "antigravity" ? "Google" : (driver?.label ?? "Custom provider"); + const quotaRows = (rows: typeof windows) => ( +
    + {rows.map((window) => { + const remaining = remainingPercent(window); + const reset = formatResetsIn(window, now); + return ( +
    +
    +
    + {quotaLabel(account, window)} +
    +
    + {remaining}%{" "} + + left{remaining === 0 ? " · Exhausted" : ""} + +
    +
    +
    +
    +
    + {reset ?

    {reset}

    : null} +
    + ); + })} +
    + ); + return ( +
    +
    +
    + {driver ? ( + + ) : ( + + )} +
    +

    {account.label}

    +

    + {providerLabel} + · + + {isApi ? "API" : account.category} + {showEnvironment ? · {account.environmentLabel} : null} + {account.status !== "Connected" ? · {account.status} : null} + {exhaustedCount > 0 ? ( + · {exhaustedCount} exhausted + ) : null} + {limits?.resetCredits?.availableCount ? ( + + + {limits.resetCredits.availableCount} + quota resets available + + ) : null} +

    +
    +
    +
    + {summaryWindows.map((window) => ( +
    +
    + {quotaLabel(account, window)} +
    +
    + {remainingPercent(window)}%{" "} + left +
    +
    +
    +
    +
    + ))} + {credits ? ( +
    +
    Credits remaining
    +
    + +
    +
    + ) : null} + {isApi ? ( +
    +
    + Credits remaining + {prepaidBalance && limits?.unavailable?.reason === "probeFailed" ? " · stale" : ""} +
    +
    + {prepaidBalance ? formatUsd(prepaidBalance.remainingUsd) : "—"} + {creditPercent !== null ? ( + + {creditPercent.toLocaleString(undefined, { maximumFractionDigits: 1 })}% left + + ) : null} +
    + {creditPercent !== null ? ( +
    +
    +
    + ) : null} + {!prepaidBalance ? ( +

    Balance unavailable

    + ) : null} +
    + ) : null} + {!windows.length && !credits && !isApi ? ( +
    +
    Usage status
    +
    + {limits?.unavailable?.reason === "probeFailed" + ? "Refresh to retry usage" + : "Usage unavailable"} +
    +
    + ) : null} +
    + +
    + +
    + ); +} + +export function UsageAccounts({ + accounts, + now, + density, + onDensityChange, + historyEnvironments = NO_HISTORY, +}: { + readonly accounts: readonly UsageAccount[]; + readonly now: number; + readonly density: Density; + readonly onDensityChange: (density: Density) => void; + readonly historyEnvironments?: readonly UsageAccountHistoryEnvironment[]; +}) { + const showEnvironment = new Set(accounts.map((account) => account.environmentId)).size > 1; + const headingId = useId(); + const grid = useRef(null); + const focusedAccount = useRef(null); + const keys = JSON.stringify(accounts.map((account) => account.key)); + useLayoutEffect(() => { + const present: readonly string[] = JSON.parse(keys); + if (focusedAccount.current && !present.includes(focusedAccount.current)) { + if (document.activeElement === document.body) grid.current?.focus(); + focusedAccount.current = null; + } + }, [keys]); + + return ( +
    +
    +

    + Accounts ({accounts.length}) +

    + { + const value = values[0]; + if (value === "compact" || value === "comfortable") onDensityChange(value); + }} + > + + Compact + + + Comfortable + + +
    +
    { + if ( + event.relatedTarget instanceof Node && + !event.currentTarget.contains(event.relatedTarget) + ) + focusedAccount.current = null; + }} + className="space-y-2 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" + > + {accounts.map((account) => ( + { + focusedAccount.current = account.key; + }} + /> + ))} +
    +
    + ); +} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index bac2682a0245e..e7b5c95605a11 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -368,11 +368,17 @@ export function UsageLimitsSection({ hiddenProviders, now, cursorPrompt, + density, + onDensityChange, + historyEnvironments, }: { readonly selectedEnvironmentIds: ReadonlySet | null; readonly hiddenProviders: ReadonlySet; readonly now: number; readonly cursorPrompt?: ReactNode; + readonly density: "compact" | "comfortable"; + readonly onDensityChange: (density: "compact" | "comfortable") => void; + readonly historyEnvironments: readonly import("@t3tools/shared/usageLimits").UsageAccountHistoryEnvironment[]; }) { const presentations = useAtomValue(environmentPresentations.presentationsWithProvidersAtom); const hiddenDrivers = new Set( @@ -404,5 +410,14 @@ export function UsageLimitsSection({ ]; }), ); - return ; + return ( + + ); } diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 7c2afe6707e58..a192093bfeae2 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -1,8 +1,7 @@ import { - CHATGPT_USAGE_URL, ANTIGRAVITY_USAGE_SCOPE, collectLimitAccounts, - collectExternalUsageLinks, + collectUsageAccounts, collectLimitNotices, collectLimitPools, usageWindowDetails, @@ -16,10 +15,9 @@ import { remainingPercent, } from "@t3tools/shared/usageLimits"; import type { Popover as PopoverPrimitive } from "@base-ui/react/popover"; -import { AlertTriangleIcon, ExternalLinkIcon, TicketIcon } from "lucide-react"; +import { AlertTriangleIcon, TicketIcon } from "lucide-react"; import { createContext, - Fragment, type ReactNode, type RefObject, use, @@ -28,7 +26,6 @@ import { useState, } from "react"; -import { ensureLocalApi } from "../../localApi"; import { usePrimarySettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { formatUpcomingTimestamp } from "../../timestampFormat"; @@ -36,7 +33,7 @@ import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { providerClients } from "../settings/providerDriverMeta"; import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { Button } from "../ui/button"; -import { OpenAI } from "../Icons"; +import { UsageAccounts } from "./UsageAccounts"; import { Alert, AlertTitle } from "../ui/alert"; import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { @@ -606,36 +603,52 @@ export function UsageLimitsPooled({ presentations, now, cursorPrompt, + density, + onDensityChange, + historyEnvironments, }: { readonly presentations: Parameters[0]; readonly now: number; readonly cursorPrompt?: ReactNode; + readonly density: "compact" | "comfortable"; + readonly onDensityChange: (density: "compact" | "comfortable") => void; + readonly historyEnvironments: readonly import("@t3tools/shared/usageLimits").UsageAccountHistoryEnvironment[]; }) { const [segmentPopover] = useState(() => PopoverCreateHandle()); const segmentPopoverActions = useRef(null); const pools = collectLimitPools(collectLimitAccounts(presentations), now); const notices = collectLimitNotices(presentations); - const externalLinks = collectExternalUsageLinks(presentations); - const cursorPromptAt = - Math.max( - pools.findIndex((pool) => pool.driver === "codex"), - pools.findIndex((pool) => pool.driver === "claudeAgent"), - ) + 1; + const accounts = collectUsageAccounts(presentations); return ( -
    - {pools.length === 0 && notices.length === 0 && !cursorPrompt && externalLinks.length === 0 ? ( +
    + + {accounts.length === 0 && notices.length === 0 && !cursorPrompt ? (

    - No provider on the selected environments reports subscription limits. + Add a provider account in Settings to see its usage here.

    ) : null} - - {pools.map((pool, index) => ( - - {index === cursorPromptAt ? cursorPrompt : null} - - - ))} - + {cursorPrompt} + {pools.length > 0 ? ( +
    + + Pooled quota details{" "} + · {pools.length} providers + +
    + + {pools.map((pool) => ( + + ))} + +
    +
    + ) : null} {({ payload }) => payload ? ( @@ -649,37 +662,6 @@ export function UsageLimitsPooled({ ) : null } - {cursorPromptAt === pools.length ? cursorPrompt : null} - {externalLinks.map((link) => ( -
    -
    - {link.url === CHATGPT_USAGE_URL ? ( -
    - -
    - ))}
    ); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 4a940a8d3572f..3a57e37456ed0 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -304,15 +304,6 @@ export function UsagePage() { const refreshWindow = () => { if (refreshingRef.current) return; - if (showingLimits) { - refreshingRef.current = true; - setIsRefreshing(true); - void refreshLimits().finally(() => { - refreshingRef.current = false; - setIsRefreshing(false); - }); - return; - } const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( nextWindow.sinceDay !== window.sinceDay || @@ -324,7 +315,10 @@ export function UsagePage() { } refreshingRef.current = true; setIsRefreshing(true); - void refresh(nextWindow).finally(() => { + void Promise.allSettled([ + refresh(nextWindow), + ...(showingLimits ? [refreshLimits()] : []), + ]).finally(() => { refreshingRef.current = false; setIsRefreshing(false); }); @@ -346,6 +340,28 @@ export function UsagePage() { if (showingLimits && connectedLimitsEnvironments) autoRefreshLimits(); }, [showingLimits, connectedLimitsEnvironments]); + const refreshRecordedActivity = useEffectEvent(() => { + if (document.visibilityState !== "visible" || refreshingRef.current) return; + refreshingRef.current = true; + const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); + if ( + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime + ) { + setWindowSelection({ days: windowDays, window: nextWindow }); + } + void Promise.allSettled([refresh(nextWindow), refreshLimits(true)]).finally(() => { + refreshingRef.current = false; + }); + }); + useEffect(() => { + if (!showingLimits) return; + const timer = globalThis.window.setInterval(refreshRecordedActivity, 60_000); + return () => globalThis.window.clearInterval(timer); + }, [showingLimits]); + const days = enumerateDays(shownWindow.sinceDay, shownWindow.untilDay); const hours = shownWindow.sinceTime === undefined || shownWindow.untilTime === undefined @@ -417,7 +433,6 @@ export function UsagePage() { aria-label="Usage period" variant="segmented" value={[String(windowDays)]} - disabled={showingLimits} onValueChange={(next) => { const value = next[0]; if (value) selectWindow(Number(value)); @@ -465,11 +480,7 @@ export function UsagePage() { ))} - selectWindow(Number(value))}> {topbarContent} - + {selectedEnvironments.length === 0 ? (

    {environments.length === 0 @@ -524,6 +535,9 @@ export function UsagePage() { selectedEnvironmentIds={selectedEnvironmentIds} hiddenProviders={hiddenProviders} now={limitsNow} + historyEnvironments={selectedEnvironments} + density={preferences.density ?? "compact"} + onDensityChange={(density) => updatePreferences({ density })} cursorPrompt={ cursorAccessEnvironments.length > 0 ? ( { }); describe("Usage page preferences", () => { + it("persists Usage density while preserving earlier saved selections", () => { + saveUsagePagePreferences({ metric: "limits", windowDays: 30, density: "comfortable" }); + expect(readUsagePagePreferences().density).toBe("comfortable"); + values.set(key, '{"metric":"limits","windowDays":30}'); + expect(readUsagePagePreferences().density ?? "compact").toBe("compact"); + }); + it("round-trips hidden providers and reads older saves without them", () => { saveUsagePagePreferences({ metric: "cost", windowDays: 7, hiddenProviders: ["codex"] }); expect(readUsagePagePreferences().hiddenProviders).toEqual(["codex"]); diff --git a/apps/web/src/components/usage/usagePagePreferences.ts b/apps/web/src/components/usage/usagePagePreferences.ts index 1aeaefc43da12..e35c2ccc6ef4b 100644 --- a/apps/web/src/components/usage/usagePagePreferences.ts +++ b/apps/web/src/components/usage/usagePagePreferences.ts @@ -8,6 +8,7 @@ const UsagePagePreferencesSchema = Schema.Struct({ metric: Schema.Literals(["cost", "tokens", "limits"]), windowDays: Schema.Literals([1, 7, 30, 90]), hiddenProviders: Schema.optional(Schema.Array(UsageProviderKind)), + density: Schema.optional(Schema.Literals(["compact", "comfortable"])), }); export type UsagePagePreferences = typeof UsagePagePreferencesSchema.Type; diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index 9ecee2a0439c2..0c7682e773bdf 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -39,8 +39,10 @@ session. ## Approvals -OpenCode follows the shared [permission modes](./permission-modes.md). **Auto** has -the same rules as **Supervised** because OpenCode has no AI approval reviewer. +OpenCode follows the shared [permission modes](./permission-modes.md). On OpenCode 2, +**Auto** approves ordinary existing-file edits and a small set of literal read commands +inside the workspace. Protected paths, unfamiliar shell syntax, and uncertain paths +continue through approval. OpenCode 1.x uses the **Supervised** rules for Auto. Environment files such as `.env` and `.env.local` need approval in restricted modes even though normal file reads do not; `.env.example` is allowed. @@ -64,3 +66,34 @@ restart before T3 Code can see configuration changes. Existing threads keep their selected model and options even when it disappears from the catalog. If OpenCode rejects that model, select an available one and retry. + +## Private model workflows + +Selecting Tinfoil changes the inference connection. Explicit private workflows add a +routing lock for the conversation, child tasks, and background model requests. Auto +continues to control action permissions independently. + +After the environment owner approves a verified Tinfoil connection, select a project +in **Settings → General**, enable **Private workflow**, and start a fresh conversation. +Existing conversations keep their earlier disclosure history. A private conversation +keeps its routing lock when project defaults change; start a fresh Standard conversation +to use another route. An unavailable or changed private connection pauses model work. +Imports of private conversation context require a locked private destination. Scheduled +work created through a private coordinator stays bound to its locked conversation. + +Private conversations default to **Private + helpers**. Ask the private coordinator to +prepare a self-contained coding task using synthetic fixtures and interfaces, then open +**Private controls** in the composer and refresh the packet list. Inspect the exact +outbound packet before approving it once. Initial helper support uses local OpenCode 2 +connections, including OpenCode Go. The external provider's data terms apply to everything +in that packet. Review identities, business details, and proprietary logic yourself. + +Helpers receive the approved prompt through a stateless text call. Returned code stays +an artifact for the private coordinator to inspect with its helper-status tool. Ask it +to review the result before applying or executing anything. Reviews expire after +15 minutes or a server restart. Choose **Private only** for tasks that should keep all +model context inside the private connection, including sensitive financial or legal work. + +This feature enforces T3's model routes and helper handoffs. Workspace tools retain their +selected execution and network permissions, and saved conversations use T3's existing +local storage. Configure device encryption, backups, and local access for those records. diff --git a/docs/user/usage.md b/docs/user/usage.md index a012e73d94b89..a83ce1810dfaa 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -78,9 +78,31 @@ the dialog. ## Track subscription limits -**Usage → Limits** pools every subscription account it can see per provider, so with several Codex -or Claude accounts across your enabled environments and hubs you read one number per window rather than a -list. Each window card shows how much of the pool is left and a bar with one segment per account, +**Usage → Limits** shows a named row for every enabled provider connection on the selected +environments, including API-key accounts and accounts awaiting setup or usage data. New connections +appear automatically. API rows show estimated cost when the profile belongs to one connection; +recorded tokens appear in the account details. On web and desktop, the selected history period applies to this +ticker, which refreshes once a minute while Limits is visible. **Cost** and **Tokens** include +recorded sessions. Local history shared by several connections remains combined in those totals. +Provider billing links give account-wide charges and prepaid balances; standard Anthropic workspace +keys provide inference access, while organization billing APIs require administrative access. + +Codex cards show provider-reported usage credits separately from quota-reset credits when available. A provider can report that credits exist +without disclosing their balance. Google cards show each Gemini model's reported allowance and +reset independently of the shared Claude/GPT allowance. Expand additional quotas to see every model. + +Tinfoil connections configured with Tinfoil-only model IDs show recorded API spend and token counts +from their OpenCode profile. Account-wide Tinfoil billing reports require a separate admin key. +The billing link opens the Tinfoil dashboard; local estimates cover activity recorded in this profile. + +Account rows keep balances, key allowances, and exhausted-quota counts visible. Expand **Details** +to see all reported allowances, reset times, billing links, and available quota resets. Selecting +**Credits remaining** also expands those details. Web and desktop use smaller type and spacing in +**Compact** mode. Choose **Comfortable** for more space; the choice is saved for that browser. + +Expand **Pooled quota details** to compare combined subscription allowances. Several Codex or Claude +accounts across your enabled environments and hubs contribute to one number per window. Each +window card shows how much of the pool is left and a bar with one segment per account, kept in the same column across windows. Accounts are ordered by their next 5-hour refill, soonest first. For an account with no weekly quota left, this is when its session becomes usable again. The first available window is used when no account reports a 5-hour limit. A gap means the @@ -93,7 +115,7 @@ Claude resets are not available when the server runs on macOS, where Claude keep Keychain. On narrow screens, numbered rows below the bar show each account's quota, countdown, and credits. Tap a row to open its details. -The same account signed in on more than one environment, or reported by a hub as well, counts once. +In pooled summaries, the same account signed in on more than one environment, or reported by a hub as well, counts once. Filter with the environment dropdown to see what a single machine has. When a Claude or Codex account has no overall weekly quota left, its pooled session contribution @@ -113,8 +135,8 @@ anything. The command is offered only for providers that appear under **Usage OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials -belong to the remote server. Limits need an OpenCode Go API key, or an OpenCode Console connected -in OpenCode with an API key. A Console browser sign-in alone does not report them. You can also add +belong to the remote server. Limits use the selected public OpenCode Console connection, +including its browser sign-in, or an OpenCode Go API key. You can also add your Go API key as `OPENCODE_API_KEY` in the OpenCode instance's **Environment variables**, then refresh provider status. Cursor reports its monthly allowance, including separate Auto and API usage, using the CLI login or @@ -126,6 +148,14 @@ Grok reports the remaining subscription allowance and reset time for its current after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication or endpoint configurations do not report subscription limits. +Tinfoil accounts read their prepaid balance through the billing API. In the OpenCode account’s +provider settings, add a **Tinfoil billing admin key** for the same personal account or organization. +Billing requires an unscoped admin key; inference keys cannot read balances. T3 stores the key +separately on the host and uses read-only billing requests. The percentage compares remaining +balance with settled purchases and granted credits. Pending top-ups are excluded. If the credit +history is unavailable, T3 shows the live dollar balance while leaving the percentage unavailable. +Recorded spend remains in Details and the Cost view. + API-key accounts may not report subscription limits. This also applies to Claude connections using a proxy through `ANTHROPIC_AUTH_TOKEN`. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index db343d2d0f8f1..1f5345ab6e21d 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -119,6 +119,8 @@ export interface VisitThreadInput extends ThreadCommandInput { export type MarkThreadUnreadInput = ThreadCommandInput; export interface UpdateThreadMetadataInput extends ThreadCommandInput { + readonly modelPrivacy?: "private"; + readonly privateHelpers?: import("@t3tools/contracts").PrivateHelpersPolicy; readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecoveryUpdate | null; readonly title?: string; readonly modelSelection?: ModelSelection; @@ -560,6 +562,8 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM let result = null; if ( input.title !== undefined || + input.modelPrivacy !== undefined || + input.privateHelpers !== undefined || input.branch !== undefined || input.worktreePath !== undefined || input.regenerateTitle !== undefined || @@ -568,6 +572,8 @@ export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadM ) { result = yield* dispatch({ type: "thread.metadata.update", + ...(input.modelPrivacy === undefined ? {} : { modelPrivacy: input.modelPrivacy }), + ...(input.privateHelpers === undefined ? {} : { privateHelpers: input.privateHelpers }), ...(input.limitRecovery === undefined ? {} : { limitRecovery: input.limitRecovery }), commandId, threadId: input.threadId, diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index 5ba27c7ba3ae8..8bb2c32e3a54d 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -12,6 +12,7 @@ import { AuthOrchestrationReadScope, AuthPreviewOperateScope, AuthSettingsWriteScope, + AuthProvidersManageScope, AuthSourceControlWriteScope, ThreadId, EnvironmentId, @@ -61,6 +62,33 @@ const setup = Effect.gen(function* () { }); describe("command permissions", () => { + it.effect("requires the selected environment's provider-management grant for billing keys", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + for (const method of [ + WS_METHODS.providerConnectTinfoilBilling, + WS_METHODS.providerConfigureClaudeBilling, + ]) { + const command = createCommandPermissions(runtime, method); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(command.permissionAtom(env))).toBe(false); + const denied = yield* command.authorize(registry, env).pipe(Effect.flip); + expect(denied._tag).toBe("EnvironmentAuthorizationError"); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(true), + scopes: [AuthProvidersManageScope], + permissions: [AuthProvidersManageScope], + }), + ); + expect(registry.get(command.permissionAtom(env))).toBe(true); + yield* command.authorize(registry, env); + } + }), + ), + ); it.effect("requires the selected environment's operate grant for local handoff files", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index dca3b36aa9991..8f216f3663f1e 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1360,6 +1360,18 @@ export function createServerEnvironmentAtoms( JSON.stringify([environmentId, input.threadId, input.turnItemId]), }, }), + getPrivateHelperState: createEnvironmentRpcCommand(runtime, { + label: "environment-data:privacy:helper-state", + tag: WS_METHODS.privacyGetHelperState, + }), + decidePrivateHelper: createEnvironmentRpcCommand(runtime, { + label: "environment-data:privacy:helper-decision", + tag: WS_METHODS.privacyDecideHelper, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => `${environmentId}:${input.packetId}`, + }, + }), refreshUsageRates: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-usage-rates", tag: WS_METHODS.serverRefreshUsageRates, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 95af5d989f1a8..40186c930b1e9 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -6,6 +6,7 @@ import { AuthOrchestrationReadScope, AuthPreviewOperateScope, AuthSettingsWriteScope, + AuthProvidersManageScope, AuthSourceControlWriteScope, type AuthEnvironmentScope, } from "./auth.ts"; @@ -14,6 +15,10 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.privacyGetHelperState]: AuthOrchestrationReadScope, + [WS_METHODS.privacyDecideHelper]: AuthOrchestrationOperateScope, + [WS_METHODS.providerConnectTinfoilBilling]: AuthProvidersManageScope, + [WS_METHODS.providerConfigureClaudeBilling]: AuthProvidersManageScope, [WS_METHODS.serverUpstream]: AuthEnvironmentMaintainScope, [WS_METHODS.serverCreateLocalThreadHandoff]: AuthOrchestrationOperateScope, [WS_METHODS.serverRemoveLocalThreadHandoff]: AuthOrchestrationOperateScope, diff --git a/packages/contracts/src/desktopBrowser.ts b/packages/contracts/src/desktopBrowser.ts index c70b374910079..88825d49ca76c 100644 --- a/packages/contracts/src/desktopBrowser.ts +++ b/packages/contracts/src/desktopBrowser.ts @@ -1,6 +1,10 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + ClaudeConsoleBillingConnection, + ClaudeConsoleBillingReadError, +} from "./providerBilling.ts"; const TabKey = { threadId: TrimmedNonEmptyString, @@ -8,6 +12,14 @@ const TabKey = { }; export const DesktopBrowserEvent = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("claudeBilling"), + requestId: TrimmedNonEmptyString, + result: Schema.Union([ + Schema.Struct({ _tag: Schema.Literal("Success"), credits: Schema.Unknown }), + Schema.Struct({ _tag: Schema.Literal("Failure"), error: ClaudeConsoleBillingReadError }), + ]), + }), Schema.Struct({ type: Schema.Literal("attached"), ...TabKey }), /** Its `` went away: closed, crashed, swapped, or devtools took the debugger. */ Schema.Struct({ type: Schema.Literal("detached"), ...TabKey }), @@ -16,6 +28,12 @@ export const DesktopBrowserEvent = Schema.Union([ export type DesktopBrowserEvent = typeof DesktopBrowserEvent.Type; export const DesktopBrowserCommand = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("claudeBilling"), + requestId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + connection: ClaudeConsoleBillingConnection, + }), Schema.Struct({ type: Schema.Literal("cdp"), ...TabKey, message: Schema.String }), Schema.Struct({ type: Schema.Literal("release"), ...TabKey }), Schema.Struct({ diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 46c90934d69cd..df1c61166c8e9 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -118,6 +118,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ usageLimitSources: Schema.optionalKey(Schema.Boolean), usagePriceOverrides: Schema.optionalKey(Schema.Boolean), usageModelAliases: Schema.optionalKey(Schema.Boolean), + /** Secret billing fields require server-side credential storage support. */ + tinfoilBilling: Schema.optionalKey(Schema.Boolean), + claudeConsoleBilling: Schema.optionalKey(Schema.Boolean), + privateWorkflow: Schema.optionalKey(Schema.Boolean), threadPinning: Schema.optionalKey(Schema.Boolean), threadPinReorder: Schema.optionalKey(Schema.Boolean), threadActiveReorder: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1cff24dae8a49..e3462baacea56 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -22,6 +22,8 @@ export * from "./providerUsageLimits.ts"; export * from "./usageLimitSourceId.ts"; export * from "./providerPolicy.ts"; export * from "./modelSelection.ts"; +export * from "./modelPrivacy.ts"; +export * from "./privateHelpers.ts"; export * from "./chatAttachment.ts"; export * from "./threadHandoff.ts"; export * from "./checkpointDiff.ts"; @@ -68,3 +70,5 @@ export * from "./worktreeSetup.ts"; export * from "./secretRequest.ts"; export * from "./clientRpcPermissions.ts"; export * from "./upstream.ts"; + +export * from "./providerBilling.ts"; diff --git a/packages/contracts/src/modelPrivacy.ts b/packages/contracts/src/modelPrivacy.ts new file mode 100644 index 0000000000000..01dec277c0126 --- /dev/null +++ b/packages/contracts/src/modelPrivacy.ts @@ -0,0 +1,16 @@ +import * as Schema from "effect/Schema"; + +import { ModelSelection } from "./modelSelection.ts"; + +export const ModelPrivacy = Schema.Literals(["standard", "private"]); +export type ModelPrivacy = typeof ModelPrivacy.Type; + +export const PrivateHelpersPolicy = Schema.Literals(["disabled", "review"]); +export type PrivateHelpersPolicy = typeof PrivateHelpersPolicy.Type; + +export const PrivateModelRoute = Schema.Struct({ + modelSelection: ModelSelection, + // Bind approval to the local provider configuration, including its launcher and environment. + providerConfigHash: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)), +}); +export type PrivateModelRoute = typeof PrivateModelRoute.Type; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 881a316f59ae1..3b8f289dd2a09 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -44,6 +44,7 @@ import { OrchestrationGetTurnDiffResult, } from "./checkpointDiff.ts"; import { ModelSelection } from "./modelSelection.ts"; +import { PrivateHelpersPolicy, PrivateModelRoute } from "./modelPrivacy.ts"; import { ThreadLinkedPullRequest, ThreadPullRequestLink, @@ -377,6 +378,9 @@ export const OrchestrationV2AppThread = Schema.Struct({ title: TrimmedNonEmptyString, providerInstanceId: ProviderInstanceId, modelSelection: ModelSelection, + privateModelRoute: Schema.optional(PrivateModelRoute), + privateHelpers: Schema.optional(PrivateHelpersPolicy), + privateWorkspacePaths: Schema.optional(Schema.Array(TrimmedNonEmptyString)), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), @@ -1813,6 +1817,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ title: Schema.String, providerInstanceId: ProviderInstanceId, modelSelection: ModelSelection, + privateModelRoute: Schema.optional(PrivateModelRoute), + privateHelpers: Schema.optional(PrivateHelpersPolicy), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), @@ -2581,6 +2587,8 @@ export const OrchestrationV2Command = Schema.Union([ projectId: ProjectId, title: TrimmedNonEmptyString, modelSelection: ModelSelection, + private: Schema.optional(Schema.Boolean), + privateRouteSourceThreadId: Schema.optional(ThreadId), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), @@ -2691,6 +2699,8 @@ export const OrchestrationV2Command = Schema.Union([ type: Schema.Literal("thread.metadata.update"), commandId: CommandId, threadId: ThreadId, + modelPrivacy: Schema.optional(Schema.Literal("private")), + privateHelpers: Schema.optional(PrivateHelpersPolicy), title: Schema.optional(TrimmedNonEmptyString), regenerateTitle: Schema.optional(Schema.Boolean), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), diff --git a/packages/contracts/src/privateHelpers.ts b/packages/contracts/src/privateHelpers.ts new file mode 100644 index 0000000000000..9fd707cef806b --- /dev/null +++ b/packages/contracts/src/privateHelpers.ts @@ -0,0 +1,73 @@ +import * as Schema from "effect/Schema"; +import { ModelSelection } from "./modelSelection.ts"; +import { ThreadId } from "./baseSchemas.ts"; + +const BoundedText = Schema.String.check(Schema.isMaxLength(32_768)); +export const PrivateHelperTask = Schema.Struct({ + objective: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(4096)), + files: Schema.Array(Schema.Struct({ path: Schema.String, content: BoundedText })).check( + Schema.isMaxLength(8), + ), + outputPaths: Schema.Array(Schema.String).check(Schema.isMinLength(1), Schema.isMaxLength(8)), +}); +export type PrivateHelperTask = typeof PrivateHelperTask.Type; +export const PrivateHelperProposalInput = Schema.Struct({ + threadId: ThreadId, + modelSelection: ModelSelection, + task: PrivateHelperTask, +}); +export type PrivateHelperProposalInput = typeof PrivateHelperProposalInput.Type; +export const PrivateHelperPacket = Schema.Struct({ + id: Schema.String, + digest: Schema.String, + modelSelection: ModelSelection, + task: PrivateHelperTask, + prompt: BoundedText, + status: Schema.Literals(["awaiting-review", "running", "completed", "failed", "blocked"]), + artifact: Schema.optional(BoundedText), + createdAt: Schema.String, +}); +export type PrivateHelperPacket = typeof PrivateHelperPacket.Type; +export const PrivateHelperState = Schema.Struct({ packets: Schema.Array(PrivateHelperPacket) }); +export const PrivateHelperStateInput = Schema.Struct({ threadId: ThreadId }); +export const PrivateHelperDecisionInput = Schema.Struct({ + threadId: ThreadId, + packetId: Schema.String, + digest: Schema.String, + decision: Schema.Literals(["approve", "block"]), +}); +export type PrivateHelperDecisionInput = typeof PrivateHelperDecisionInput.Type; + +export class PrivateHelperError extends Schema.TaggedError()( + "PrivateHelperError", + { + reason: Schema.Literals([ + "private-required", + "helpers-disabled", + "unavailable", + "invalid-packet", + "expired", + "changed", + "execution-failed", + ]), + }, +) { + override get message(): string { + switch (this.reason) { + case "private-required": + return "Helper packets require an explicitly private thread."; + case "helpers-disabled": + return "External helpers are disabled for this private thread."; + case "unavailable": + return "This destination has no qualified stateless helper transport. No packet was sent."; + case "invalid-packet": + return "The helper packet needs bounded, self-contained synthetic inputs and relative output paths."; + case "expired": + return "This helper packet expired or was already decided. Prepare a new packet."; + case "changed": + return "The reviewed packet or its connection changed. Prepare a new packet for review."; + case "execution-failed": + return "The helper could not return a bounded artifact. No files were applied."; + } + } +} diff --git a/packages/contracts/src/providerBilling.ts b/packages/contracts/src/providerBilling.ts new file mode 100644 index 0000000000000..8a9c281fb54b2 --- /dev/null +++ b/packages/contracts/src/providerBilling.ts @@ -0,0 +1,83 @@ +import * as Schema from "effect/Schema"; +import { ProjectId, SecretRef, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +export const ProviderConnectTinfoilBillingInput = Schema.Struct({ + instanceId: ProviderInstanceId, + projectId: ProjectId, + secretRef: SecretRef, +}); +export type ProviderConnectTinfoilBillingInput = typeof ProviderConnectTinfoilBillingInput.Type; + +export const ClaudeConsoleBillingConnection = Schema.Struct({ + profileId: TrimmedNonEmptyString, + organizationId: TrimmedNonEmptyString.check( + Schema.isPattern(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i), + ), +}); +export type ClaudeConsoleBillingConnection = typeof ClaudeConsoleBillingConnection.Type; + +export const ProviderConfigureClaudeBillingInput = Schema.Struct({ + instanceId: ProviderInstanceId, + connection: Schema.NullOr(ClaudeConsoleBillingConnection), +}); +export type ProviderConfigureClaudeBillingInput = typeof ProviderConfigureClaudeBillingInput.Type; + +export class ClaudeConsoleBillingReadError extends Schema.TaggedError()( + "ClaudeConsoleBillingReadError", + { + reason: Schema.Literals([ + "invalidBinding", + "signInRequired", + "organizationUnavailable", + "requestFailed", + "invalidResponse", + ]), + }, +) { + override get message(): string { + switch (this.reason) { + case "invalidBinding": + return "Reconnect Claude Console billing to a saved browser profile."; + case "signInRequired": + return "Sign in to Claude Console with billing access to refresh credits."; + case "organizationUnavailable": + return "The connected Claude API organization is unavailable in this browser profile."; + case "requestFailed": + return "Claude Console billing could not be refreshed. Try again."; + case "invalidResponse": + return "Claude Console returned an unsupported credit balance."; + } + } +} + +export class ProviderBillingError extends Schema.TaggedError()( + "ProviderBillingError", + { + reason: Schema.Literals([ + "connectionMissing", + "unsupportedConnection", + "invalidKey", + "secretUnavailable", + "saveFailed", + "billingUnavailable", + ]), + }, +) { + override get message(): string { + switch (this.reason) { + case "connectionMissing": + return "The provider connection no longer exists."; + case "unsupportedConnection": + return "Select a supported API connection for this billing source."; + case "invalidKey": + return "Tinfoil billing requires an admin key."; + case "secretUnavailable": + return "The private key request is unavailable. Enter it again through the private key card."; + case "saveFailed": + return "The billing key could not be saved."; + case "billingUnavailable": + return "Connect a signed-in Claude Console browser profile with access to this API organization's billing."; + } + } +} diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 894a33a05e5fc..ae3b6fa14ab62 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -31,6 +31,20 @@ export const ServerProviderResetCredits = Schema.Struct({ }); export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; +export const ServerProviderCreditBalance = Schema.Struct({ + hasCredits: Schema.Boolean, + unlimited: Schema.Boolean, + /** Provider credit units, distinct from currency and quota-reset credits. */ + balance: Schema.optional(TrimmedNonEmptyString.check(Schema.isPattern(/^\d+(?:\.\d+)?$/))), +}); +export type ServerProviderCreditBalance = typeof ServerProviderCreditBalance.Type; + +export const ServerProviderPrepaidBalance = Schema.Struct({ + remainingUsd: Schema.Finite, + totalGrantedUsd: Schema.optional(Schema.Finite.check(Schema.isGreaterThan(0))), +}); +export type ServerProviderPrepaidBalance = typeof ServerProviderPrepaidBalance.Type; + /** * `unavailable` distinguishes an account that can never report windows (API * key, Bedrock) from a probe that failed this time, so clients can keep the @@ -41,6 +55,8 @@ export const ServerProviderUsageLimits = Schema.Struct({ windows: ForwardCompatibleArray(ServerProviderUsageWindow), credentialFingerprint: Schema.optional(TrimmedNonEmptyString), resetCredits: Schema.optional(ServerProviderResetCredits), + creditBalance: Schema.optional(ServerProviderCreditBalance), + prepaidBalance: Schema.optional(ServerProviderPrepaidBalance), externalUsage: Schema.optional( Schema.Struct({ label: TrimmedNonEmptyString, @@ -63,6 +79,7 @@ export type ServerProviderUsageLimits = typeof ServerProviderUsageLimits.Type; */ export const ProviderUsageLimitsUpdate = Schema.Struct({ windows: Schema.Array(ServerProviderUsageWindow), + creditBalance: Schema.optional(Schema.NullOr(ServerProviderCreditBalance)), }); export type ProviderUsageLimitsUpdate = typeof ProviderUsageLimitsUpdate.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 98bf74a1cad70..3b52e9a0a5370 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,3 +1,15 @@ +import { + ProviderConnectTinfoilBillingInput, + ProviderConfigureClaudeBillingInput, + ProviderBillingError, +} from "./providerBilling.ts"; +import { + PrivateHelperDecisionInput, + PrivateHelperError, + PrivateHelperPacket, + PrivateHelperState, + PrivateHelperStateInput, +} from "./privateHelpers.ts"; import { UpstreamRequest, UpstreamStatus, UpstreamError } from "./upstream.ts"; import { CreateLocalThreadHandoffInput, @@ -299,7 +311,6 @@ import { DeviceSession, DeviceShutdownInput, } from "./device.ts"; -import {} from "./previewAutomation.ts"; import { ServerConfigStreamEvent, DesktopUpdateCommitInput, @@ -385,6 +396,8 @@ import { VcsError } from "./vcs.ts"; import { Project, ProjectMutation, ProjectMutationError } from "./project.ts"; export const WS_METHODS = { + privacyGetHelperState: "privacy.getHelperState", + privacyDecideHelper: "privacy.decideHelper", projectsList: "projects.list", projectsAdd: "projects.add", projectsRemove: "projects.remove", @@ -416,6 +429,8 @@ export const WS_METHODS = { providerUploadFeedback: "provider.uploadFeedback", providerAuthStart: "provider.auth.start", + providerConnectTinfoilBilling: "provider.connectTinfoilBilling", + providerConfigureClaudeBilling: "provider.configureClaudeBilling", providerConsumeResetCredit: "provider.consumeResetCredit", providerAuthComplete: "provider.auth.complete", chatGptReconnectProfile: "provider.chatgpt.reconnect-profile", @@ -636,6 +651,29 @@ const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]); +const WsProviderConnectTinfoilBillingRpc = Rpc.make(WS_METHODS.providerConnectTinfoilBilling, { + payload: ProviderConnectTinfoilBillingInput, + success: Schema.Struct({ configured: Schema.Boolean }), + error: Schema.Union([ProviderBillingError, EnvironmentAuthorizationError]), +}); + +const WsProviderConfigureClaudeBillingRpc = Rpc.make(WS_METHODS.providerConfigureClaudeBilling, { + payload: ProviderConfigureClaudeBillingInput, + success: Schema.Struct({ configured: Schema.Boolean }), + error: Schema.Union([ProviderBillingError, EnvironmentAuthorizationError]), +}); + +const WsPrivacyGetHelperStateRpc = Rpc.make(WS_METHODS.privacyGetHelperState, { + payload: PrivateHelperStateInput, + success: PrivateHelperState, + error: Schema.Union([PrivateHelperError, EnvironmentAuthorizationError]), +}); +const WsPrivacyDecideHelperRpc = Rpc.make(WS_METHODS.privacyDecideHelper, { + payload: PrivateHelperDecisionInput, + success: PrivateHelperPacket, + error: Schema.Union([PrivateHelperError, EnvironmentAuthorizationError]), +}); + const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { payload: ProviderConsumeResetCreditInput, success: ProviderConsumeResetCreditResult, @@ -1911,6 +1949,10 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, + WsProviderConnectTinfoilBillingRpc, + WsProviderConfigureClaudeBillingRpc, + WsPrivacyGetHelperStateRpc, + WsPrivacyDecideHelperRpc, WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, WsProviderAuthCompleteRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fbe4d945cff25..55671fc509b97 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -29,6 +29,7 @@ import { ProviderOptionSelections, } from "./model.ts"; import { ModelSelection } from "./modelSelection.ts"; +import { ModelPrivacy, PrivateModelRoute } from "./modelPrivacy.ts"; import { ProjectScript, type ProjectMutation } from "./project.ts"; import { DEFAULT_RUNTIME_MODE, RuntimeMode } from "./providerPolicy.ts"; import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; @@ -669,6 +670,11 @@ const InstanceIdleCompaction = Schema.Literals( export const ClaudeSettings = makeProviderSettingsSchema( { + consoleBilling: TrimmedString.pipe( + secretSetting, + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), @@ -968,6 +974,7 @@ export const WorktreeCleanup = Schema.NullOr( export type WorktreeCleanup = typeof WorktreeCleanup.Type; export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ + "modelPrivacy", "worktreeCleanup", "defaultModelSelection", "defaultRuntimeMode", @@ -1011,6 +1018,7 @@ export type ProjectProviderInstanceOverride = typeof ProjectProviderInstanceOver * settings.json shape but overrides field by field, per `ProviderInstanceId`. */ export const ProjectSettingsOverrides = Schema.Struct({ + modelPrivacy: Schema.optionalKey(ModelPrivacy), worktreeCleanup: Schema.optionalKey(WorktreeCleanup), defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), defaultRuntimeMode: Schema.optionalKey(RuntimeMode), @@ -1128,6 +1136,10 @@ export const StorageCleanupSettings = Schema.Struct({ export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; export const ServerSettings = Schema.Struct({ + modelPrivacy: ModelPrivacy.pipe(Schema.withDecodingDefault(Effect.succeed("standard"))), + privateModelRoute: Schema.NullOr(PrivateModelRoute).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), // Null means no client has migrated this preference yet; an empty value is // authoritative so another device cannot resurrect cleared preferences. favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))), @@ -1473,6 +1485,8 @@ const ModelSelectionPatch = Schema.Struct({ }); export const ServerSettingsPatch = Schema.Struct({ + modelPrivacy: Schema.optionalKey(ModelPrivacy), + privateModelRoute: Schema.optionalKey(Schema.NullOr(PrivateModelRoute)), setModelFavorites: Schema.optionalKey( Schema.Array(Schema.Struct({ ...ModelFavorite.fields, favorite: Schema.Boolean })), ), diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 29cddca8ce381..757d99f01574b 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -1,6 +1,7 @@ import * as Schema from "effect/Schema"; import { ForwardCompatibleArray, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; /** * Bumped whenever the shape of {@link UsageSummary} changes incompatibly. @@ -97,6 +98,8 @@ export type UsageSourceStatus = typeof UsageSourceStatus.Type; export const UsageSource = Schema.Struct({ fingerprint: UsageSourceFingerprint, + /** Explicit configured profile bindings; multiple ids mean shared local history. */ + providerInstanceIds: Schema.optionalKey(Schema.Array(ProviderInstanceId)), status: UsageSourceStatus, scannedFiles: NonNegativeInt, skippedFiles: NonNegativeInt, diff --git a/packages/provider-core/src/client.ts b/packages/provider-core/src/client.ts index b2974dce467a6..19ad993d04cdc 100644 --- a/packages/provider-core/src/client.ts +++ b/packages/provider-core/src/client.ts @@ -36,12 +36,23 @@ export interface ProviderClientDefinition { readonly driverKind: ProviderDriverKind; readonly label: string; readonly icon?: ProviderIcon; + readonly instanceIcons?: Readonly>; readonly settingsSchema: ProviderSettingsSchema; readonly environmentFields?: ReadonlyArray; readonly hasDefaultInstance?: boolean; readonly badgeLabel?: string; } +export function resolveProviderIcon( + client: ProviderClientDefinition | undefined, + instanceId: string | null | undefined, +): ProviderIcon | undefined { + if (instanceId && client?.instanceIcons && Object.hasOwn(client.instanceIcons, instanceId)) { + return client.instanceIcons[instanceId]; + } + return client?.icon; +} + export function defineProviderClient( definition: Definition, ): Definition { diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index ad78126cc00ac..2463f86bd1b5b 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -1,5 +1,7 @@ import type { BackgroundScope, + ThreadId, + ModelSelection, ChatAttachment, ServerSettings, ServerSettingsError, @@ -43,6 +45,11 @@ export class ProviderHost extends Context.Service< /** Changes buffered from the moment the scoped subscription is acquired. */ readonly subscribe: Effect.Effect, never, Scope.Scope>; }; + /** Fail closed when a delayed provider retry can no longer use this selection. */ + readonly canRetryTurn?: ( + threadId: ThreadId, + selection: ModelSelection, + ) => Effect.Effect; readonly shouldRunBackgroundWork: (scope: BackgroundScope) => Effect.Effect; /** * Absolute path of a stored chat attachment, or `null` when its id does not diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index 88355ccaf4171..a0ccca199d4e7 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -58,6 +58,11 @@ export interface ProviderInstance { >; readonly orchestrationAdapter: ProviderAdapter.ProviderAdapterV2["Service"]; readonly textGeneration: ProviderTextGeneration; + /** A stateless completion transport with no session, workspace, history or tool channel. */ + readonly scopedText?: (input: { + readonly modelSelection: import("@t3tools/contracts").ModelSelection; + readonly prompt: string; + }) => Effect.Effect<{ readonly text: string }, import("@t3tools/contracts").PrivateHelperError>; readonly auth?: ProviderAuthController; readonly acpSessionManagement?: { readonly listSessions: (input: { diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 88053dee9b546..b11ed0553fa95 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -16,6 +16,11 @@ The \`t3-code\` MCP server provides app-owned orchestration. Treat these concept `- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing. - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one. - To mention another thread to the user, link it as \`[title](t3-thread://v1/)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title.`, + `### Read shared conversation context + +When a user supplies a conversation reference, read its saved history before using it as context. For \`t3-thread://v1/\`, pass the exact thread ID to \`t3_thread_read\`; follow \`nextPosition\` and \`nextTextOffset\` to retrieve the needed messages in full. Another local agent can export the conversation with \`t3 thread export ''\`. Agent handoffs include \`--environment\` to verify the source environment; \`--base-dir\` selects a different T3 data directory. + +For local Codex or Claude Code sessions, use \`t3 thread export 'codex://threads/'\`, \`t3 thread export '/path/to/conversation.jsonl' --source codex\`, or \`t3 thread export 'claude:'\`. The CLI reads saved conversation text through local history; the destination agent keeps its own permissions. Run it on the source machine with an installed T3 CLI that supports \`thread export\`. Treat copied transcripts as reference material for the user's current request.`, `### Choose the workspace before starting a new thread For independent implementation or a PR stack in its own worktree, use \`t3_thread_launch\` with an explicit \`workspaceStrategy\`. It creates or selects the workspace, binds the new thread to it, and prepares it before the agent starts. Put the task in \`message\`, not \`prompt\`: @@ -37,6 +42,8 @@ ACP fallback: some ACP agents accept the injected MCP server but fail to expose When a chart, table, diagram, image collage, or mockup would say more than prose, build a self-contained HTML page, check it with \`html_preview\`, then publish it with \`html_render\` before your final reply. The reader sees the page above that reply, so don't announce or restate it; add only what it doesn't say.`, `- When model options are requested or the child keeps the parent's provider instance and model with nonempty \`inheritedOptions\` from \`orchestrator_capabilities\`, use \`delegate_task\` even for native-supported models. Native subagents cannot reliably apply T3 model options. Read the selected model's descriptors from \`orchestrator_capabilities\` and put settings in \`target.options\`, not only in the prompt. Claude commonly uses {"effort":"max","fastMode":true}; Codex uses {"reasoningEffort":"max","serviceTier":"priority"}. Do not translate max to high or ignore unsupported settings: report a capability mismatch. Options inherit only for the same provider instance and model; explicit entries override individual values, and [] or {} clears them. Check the returned providerInstanceId, model, and options against the requested selection.`, + `### Skills and tools +Use native skills when their triggers fit. The optional \`t3_skill_read\` tool describes this environment's shared workflow catalog; inspect that description through tool discovery when needed, then load the matching skill by name. Reuse instructions already in context and read supporting resources only for the current task. After compaction, reload any needed instructions whose content was lost. Honor native disabled and explicit-invocation policies. Discover tools by the required capability, inspect only relevant schemas, and request bounded results.`, ]; export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = ` diff --git a/packages/provider-core/src/server/textGeneration.ts b/packages/provider-core/src/server/textGeneration.ts index 936a4c328b13a..6b71c42ab1dd3 100644 --- a/packages/provider-core/src/server/textGeneration.ts +++ b/packages/provider-core/src/server/textGeneration.ts @@ -4,12 +4,19 @@ import type { ModelSelection, PullRequestReviewGuide, TextGenerationError, + ProjectId, + ThreadId, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type { TextGenerationPolicy } from "./textGenerationPolicy.ts"; -export interface CommitMessageGenerationInput { +interface ModelRequestContext { + readonly projectId?: ProjectId; + readonly threadId?: ThreadId; +} + +export interface CommitMessageGenerationInput extends ModelRequestContext { cwd: string; branch: string | null; stagedSummary: string; @@ -19,13 +26,17 @@ export interface CommitMessageGenerationInput { modelSelection: ModelSelection; } -export interface CommitMessageGenerationResult { +interface ModelRequestResult { + readonly modelSelection?: ModelSelection; +} + +export interface CommitMessageGenerationResult extends ModelRequestResult { subject: string; body: string; branch?: string | undefined; } -export interface PrContentGenerationInput { +export interface PrContentGenerationInput extends ModelRequestContext { cwd: string; baseBranch: string; headBranch: string; @@ -38,12 +49,12 @@ export interface PrContentGenerationInput { modelSelection: ModelSelection; } -export interface PrContentGenerationResult { +export interface PrContentGenerationResult extends ModelRequestResult { title: string; body: string; } -export interface BranchNameGenerationInput { +export interface BranchNameGenerationInput extends ModelRequestContext { naming?: BranchNamingOptions | undefined; cwd: string; message: string; @@ -51,11 +62,11 @@ export interface BranchNameGenerationInput { modelSelection: ModelSelection; } -export interface BranchNameGenerationResult { +export interface BranchNameGenerationResult extends ModelRequestResult { branch: string; } -export interface ThreadTitleGenerationInput { +export interface ThreadTitleGenerationInput extends ModelRequestContext { linkedContext?: string | undefined; cwd: string; message: string; @@ -64,7 +75,7 @@ export interface ThreadTitleGenerationInput { modelSelection: ModelSelection; } -export interface ThreadTitleGenerationResult { +export interface ThreadTitleGenerationResult extends ModelRequestResult { title: string; needsRefinement?: boolean | undefined; } @@ -76,7 +87,7 @@ export interface ReviewGuideStackLayer { readonly state: "open" | "closed" | "merged"; } -export interface ReviewGuideGenerationInput { +export interface ReviewGuideGenerationInput extends ModelRequestContext { cwd: string; number: number; title: string; @@ -92,7 +103,8 @@ export interface ReviewGuideGenerationInput { export type ReviewGuideGenerationResult = Omit< PullRequestReviewGuide, "headSha" | "generatedAt" | "modelSelection" ->; +> & + ModelRequestResult; export interface ProviderTextGeneration { readonly generateCommitMessage: ( diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts index 47fb344c7c6bd..55febf83ed99d 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -95,6 +95,8 @@ export interface ProviderUsageInstance { export interface ProviderUsageScan { readonly dir: string; + /** Explicit configured profiles; several bindings keep shared history unattributed per account. */ + readonly providerInstanceIds?: readonly ProviderInstanceId[]; readonly volumeId?: string; readonly hostId?: string; readonly status?: UsageSource["status"]; diff --git a/packages/provider-core/src/server/usageLimits.test.ts b/packages/provider-core/src/server/usageLimits.test.ts index e870dcec5ab9f..806559156986c 100644 --- a/packages/provider-core/src/server/usageLimits.test.ts +++ b/packages/provider-core/src/server/usageLimits.test.ts @@ -21,6 +21,45 @@ const weekly = { const published = { checkedAt, windows: [session, weekly] }; describe("applyUsageLimitsUpdate", () => { + it("updates and clears credit balances while preserving other account metadata", () => { + const previous = { + ...published, + creditBalance: { hasCredits: true, unlimited: false, balance: "10" }, + externalUsage: { label: "Billing", url: "https://example.invalid/billing" }, + }; + const next = applyUsageLimitsUpdate({ + previous, + checkedAt, + update: { windows: [], creditBalance: { hasCredits: true, unlimited: false, balance: "9" } }, + }); + expect(next?.creditBalance?.balance).toBe("9"); + expect(next?.externalUsage).toEqual(previous.externalUsage); + expect( + applyUsageLimitsUpdate({ + previous: next, + checkedAt, + update: { + windows: [], + creditBalance: { hasCredits: true, unlimited: false, balance: "9" }, + }, + }), + ).toBe(next); + expect( + applyUsageLimitsUpdate({ + previous: next, + checkedAt, + update: { windows: [], creditBalance: null }, + })?.creditBalance, + ).toBeUndefined(); + expect( + applyUsageLimitsUpdate({ + previous, + checkedAt, + update: { windows: [{ ...session, usedPercent: 55 }] }, + })?.creditBalance, + ).toEqual(previous.creditBalance); + }); + it("returns the published object itself when no window moved", () => { // Codex repeats the same numbers beside every token-usage tick; the // ingestion path relies on identity to skip the publish. @@ -122,3 +161,46 @@ describe("resolveUsageLimitsAfterProbe", () => { expect(resolveUsageLimitsAfterProbe({ published: undefined, probed: failed })).toBe(failed); }); }); + +describe("prepaid balance probe freshness", () => { + const balance = { + ...published, + windows: [], + prepaidBalance: { remainingUsd: 67.2, totalGrantedUsd: 96 }, + }; + it("keeps the original balance timestamp and exposes repeated refresh failures", () => { + const probed = { + checkedAt: "2026-09-03T12:05:00Z", + windows: [], + unavailable: { reason: "probeFailed" as const, message: "Billing unavailable" }, + }; + const first = resolveUsageLimitsAfterProbe({ published: balance, probed }); + expect(first?.prepaidBalance).toEqual(balance.prepaidBalance); + expect(first?.checkedAt).toBe(balance.checkedAt); + expect(first?.unavailable).toEqual(probed.unavailable); + const repeated = resolveUsageLimitsAfterProbe({ + published: first, + probed: { ...probed, checkedAt: "2026-09-03T12:10:00Z" }, + }); + expect(repeated?.prepaidBalance).toEqual(balance.prepaidBalance); + expect(repeated?.checkedAt).toBe(balance.checkedAt); + expect( + resolveUsageLimitsAfterProbe({ + published: first, + probed: { ...probed, unavailable: { reason: "unsupported" } }, + })?.prepaidBalance, + ).toBeUndefined(); + }); + it("keeps the newer balance when an older probe completes after it", () => { + const next = resolveUsageLimitsAfterProbe({ + published: balance, + probed: { + ...balance, + checkedAt: "2026-09-03T11:00:00Z", + prepaidBalance: { remainingUsd: 96, totalGrantedUsd: 96 }, + }, + }); + expect(next?.prepaidBalance?.remainingUsd).toBe(67.2); + expect(next?.checkedAt).toBe(balance.checkedAt); + }); +}); diff --git a/packages/provider-core/src/server/usageLimits.ts b/packages/provider-core/src/server/usageLimits.ts index c40775a89324a..c39450ae3d60e 100644 --- a/packages/provider-core/src/server/usageLimits.ts +++ b/packages/provider-core/src/server/usageLimits.ts @@ -53,14 +53,24 @@ export function applyUsageLimitsUpdate(input: { readonly checkedAt: string; }): ServerProviderUsageLimits | undefined { const { previous, update } = input; - if (update.windows.length === 0 || previous?.unavailable?.reason === "unsupported") { + if ( + (update.windows.length === 0 && update.creditBalance === undefined) || + previous?.unavailable?.reason === "unsupported" + ) { return previous; } const merged = new Map(previous?.windows.map((window) => [window.id, window] as const)); // Codex sends this notification beside every token-usage tick, almost // always with unchanged numbers. Decide "nothing changed" per window on // the way through so the no-op case never allocates a new snapshot. - let changed = false; + const credits = + update.creditBalance === undefined + ? previous?.creditBalance + : (update.creditBalance ?? undefined); + let changed = + credits?.hasCredits !== previous?.creditBalance?.hasCredits || + credits?.unlimited !== previous?.creditBalance?.unlimited || + credits?.balance !== previous?.creditBalance?.balance; for (const window of update.windows) { const existing = merged.get(window.id); const next: ServerProviderUsageWindow = { @@ -83,9 +93,11 @@ export function applyUsageLimitsUpdate(input: { } const unavailable = previous === undefined ? { reason: "probeFailed" as const } : previous.unavailable; + const { creditBalance: _previousCredits, ...previousLimits } = previous ?? {}; return { + ...previousLimits, ...makeUsageLimits({ checkedAt: input.checkedAt, windows: merged.values() }), - ...(previous?.resetCredits !== undefined ? { resetCredits: previous.resetCredits } : {}), + ...(credits !== undefined ? { creditBalance: credits } : {}), ...(unavailable !== undefined ? { unavailable } : {}), }; } @@ -106,6 +118,9 @@ export function resolveUsageLimitsAfterProbe(input: { readonly probed: ServerProviderUsageLimits | undefined; }): ServerProviderUsageLimits | undefined { const { published, probed } = input; + if (probed?.unavailable?.reason === "probeFailed" && published?.prepaidBalance) { + return { ...published, unavailable: probed.unavailable }; + } if (probed?.unavailable?.reason === "probeFailed" && published && !published.unavailable) { return published; } @@ -116,7 +131,13 @@ export function resolveUsageLimitsAfterProbe(input: { !probed.unavailable && Date.parse(published.checkedAt) > Date.parse(probed.checkedAt) ) { - return { ...probed, checkedAt: published.checkedAt, windows: published.windows }; + return { + ...probed, + checkedAt: published.checkedAt, + windows: published.windows, + creditBalance: published.creditBalance, + prepaidBalance: published.prepaidBalance, + }; } return probed; } diff --git a/packages/provider-opencode/src/client.test.ts b/packages/provider-opencode/src/client.test.ts new file mode 100644 index 0000000000000..f4d82a6c5ecb6 --- /dev/null +++ b/packages/provider-opencode/src/client.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; +import { resolveProviderIcon } from "@t3tools/provider-core/client"; + +import { openCodeClient } from "./client.ts"; + +describe("OpenCode provider instance branding", () => { + it("uses the Tinfoil mark for its connector instance", () => { + const icon = resolveProviderIcon(openCodeClient, "opencode-tinfoil"); + expect(icon?.viewBox).toBe("0 0 960 960"); + expect(icon?.fill).toEqual({ light: "#000000", dark: "#ffffff" }); + }); + + it("keeps the OpenCode mark for Go and other instances", () => { + for (const instanceId of ["opencode-go", "opencode", "another-account", undefined]) { + expect(resolveProviderIcon(openCodeClient, instanceId)).toBe(openCodeClient.icon); + } + }); + + it("falls back safely for object prototype names and missing drivers", () => { + expect(resolveProviderIcon(openCodeClient, "toString")).toBe(openCodeClient.icon); + expect(resolveProviderIcon(undefined, "opencode-tinfoil")).toBeUndefined(); + }); +}); diff --git a/packages/provider-opencode/src/client.ts b/packages/provider-opencode/src/client.ts index bac5cadb754ac..5f4add35c6cfb 100644 --- a/packages/provider-opencode/src/client.ts +++ b/packages/provider-opencode/src/client.ts @@ -15,4 +15,16 @@ export const openCodeClient = defineProviderClient({ { d: "M24 8H8V32H24V8ZM32 40H0V0H32V40Z" }, ], }, + instanceIcons: { + "opencode-tinfoil": { + viewBox: "0 0 960 960", + fill: { light: "#000000", dark: "#ffffff" }, + // Official mark: https://tinfoil.sh/brand-assets/icon-black.svg + paths: [ + { + d: "M955.4,718.23l-674.69,205.77L6.34,708.97,268.9,35.01l686.5,683.22ZM164.94,665.52l143.05,112.11,397.97-121.37-387.26-385.41-153.76,394.66Z", + }, + ], + }, + }, }); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 5348e66ddda0c..bd92036f6f3f7 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -1,5 +1,6 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { OpenCodeSettings } from "../settings.ts"; +import { OpenCodeSettings, resolveOpenCodeUsageProvider } from "../settings.ts"; +import { privateInferenceEnvironment } from "./privateInference.ts"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -12,6 +13,7 @@ import * as HttpClient from "effect/http/HttpClient"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as OpenCode2TextGeneration from "./v2/textGeneration.ts"; +import * as OpenCodeScopedText from "./v2/scopedText.ts"; import { makeOpenCodeTextGeneration } from "./textGeneration.ts"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; @@ -21,7 +23,7 @@ import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAda import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { openCodeUsageReader, type OpenCodeUsageReaderEnv } from "./usage.ts"; -import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; +import { readOpenCodeUsageLimits } from "./usageLimits.ts"; import { loadOpenCode2Catalog } from "./openCode2Catalog.ts"; import { checkOpenCodeProviderStatus, @@ -202,7 +204,16 @@ export const OpenCodeDriver: ProviderDriver< const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const crypto = yield* Crypto.Crypto; const host = yield* ProviderHost.ProviderHost; - const processEnv = yield* mergeProviderInstanceEnvironment(environment); + if (config.privateInference === "tinfoil" && config.serverUrl.trim().length > 0) + return yield* new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Private inference requires a locally managed OpenCode server.", + }); + const processEnv = privateInferenceEnvironment( + yield* mergeProviderInstanceEnvironment(environment), + config, + ); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, @@ -342,7 +353,11 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), ), ); + const scopedText = yield* OpenCodeScopedText.make.pipe( + Effect.provideService(OpenCode2Server.OpenCode2Server, openCode2Server), + ); + const usageProvider = resolveOpenCodeUsageProvider(effectiveConfig); const checkProvider = Effect.all( { provider: checkOpenCodeProviderStatus( @@ -351,15 +366,23 @@ export const OpenCodeDriver: ProviderDriver< runtimeProbe.refresh, loadOpenCode2Models, ), - usageLimits: readOpenCodeGoUsageLimits({ + usageLimits: readOpenCodeUsageLimits({ enabled: effectiveConfig.enabled, serverUrl: effectiveConfig.serverUrl, environment: processEnv, + usageProvider, + tinfoilAdminKey: effectiveConfig.tinfoilAdminKey, }), }, { concurrency: "unbounded" }, ).pipe( - Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), + Effect.map(({ provider, usageLimits }) => ({ + ...provider, + usageLimits, + ...(usageProvider === "tinfoil" + ? { auth: { ...provider.auth, type: "apiKey", label: "Tinfoil API" } } + : {}), + })), Effect.map(stampIdentity), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, pathService), @@ -526,6 +549,9 @@ export const OpenCodeDriver: ProviderDriver< }), orchestrationAdapter, textGeneration, + ...(effectiveConfig.privateInference === "off" && effectiveConfig.serverUrl.length === 0 + ? { scopedText } + : {}), } satisfies ProviderInstance; }), }; diff --git a/packages/provider-opencode/src/server/privateInference.test.ts b/packages/provider-opencode/src/server/privateInference.test.ts new file mode 100644 index 0000000000000..594c09027b61a --- /dev/null +++ b/packages/provider-opencode/src/server/privateInference.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; +import { OpenCodeSettings } from "../settings.ts"; +import { privateInferenceEnvironment } from "./privateInference.ts"; +const decodeSettings = Schema.decodeSync(OpenCodeSettings); + +it("replaces inherited cloud routing and disables project configuration for private inference", () => { + const config = decodeSettings({ privateInference: "tinfoil" }); + const result = privateInferenceEnvironment( + { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + model: "cloud/secret", + provider: { tinfoil: { options: { baseURL: "https://example.invalid" } } }, + }), + OPENCODE_DISABLE_PROJECT_CONFIG: "0", + }, + config, + ); + const inline = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); + expect(inline.model).toBe("tinfoil/kimi-k3"); + expect(inline.provider.tinfoil.options.baseURL).toBe("http://127.0.0.1:3301/v1"); + expect(inline.enabled_providers).toEqual(["tinfoil"]); + expect(inline.policies).toEqual([ + { action: "provider.use", resource: "*", effect: "deny" }, + { action: "provider.use", resource: "tinfoil", effect: "allow" }, + ]); + expect(result.OPENCODE_DISABLE_PROJECT_CONFIG).toBe("1"); + expect(result.OPENCODE_PURE).toBe("1"); + expect(inline.agents.title.model).toBe(inline.model); + expect(inline.agents.summary.model).toBe(inline.model); + expect(inline.agents.compaction.model).toBe(inline.model); + expect(inline.provider.tinfoil.options.apiKey).toBe("{env:TINFOIL_API_KEY}"); +}); + +it("preserves ordinary subscription configuration", () => { + const environment = { OPENCODE_CONFIG_CONTENT: "subscription-config" }; + expect(privateInferenceEnvironment(environment, decodeSettings({}))).toBe(environment); +}); diff --git a/packages/provider-opencode/src/server/privateInference.ts b/packages/provider-opencode/src/server/privateInference.ts new file mode 100644 index 0000000000000..8067aff044821 --- /dev/null +++ b/packages/provider-opencode/src/server/privateInference.ts @@ -0,0 +1,37 @@ +import type { OpenCodeSettings } from "../settings.ts"; + +export function privateInferenceEnvironment( + environment: NodeJS.ProcessEnv, + config: OpenCodeSettings, +): NodeJS.ProcessEnv { + if (config.privateInference !== "tinfoil") return environment; + const model = "tinfoil/kimi-k3"; + return { + ...environment, + OPENCODE_DISABLE_PROJECT_CONFIG: "1", + OPENCODE_PURE: "1", + OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", + OPENCODE_DISABLE_CLAUDE_CODE: "1", + OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1", + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + model, + small_model: model, + enabled_providers: ["tinfoil"], + share: "disabled", + update: "disable", + warming: false, + policies: [ + { action: "provider.use", resource: "*", effect: "deny" }, + { action: "provider.use", resource: "tinfoil", effect: "allow" }, + ], + provider: { + tinfoil: { + npm: "@ai-sdk/openai-compatible", + name: "Tinfoil", + options: { baseURL: "http://127.0.0.1:3301/v1", apiKey: "{env:TINFOIL_API_KEY}" }, + }, + }, + agents: { title: { model }, summary: { model }, compaction: { model } }, + }), + }; +} diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index f55e07d2fa02d..c281385c998f7 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -13,7 +13,7 @@ import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { beforeEach } from "vite-plus/test"; -import { OpenCodeSettings } from "../settings.ts"; +import { OpenCodeSettings, resolveOpenCodeUsageProvider } from "../settings.ts"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; import { @@ -22,7 +22,7 @@ import { type OpenCode2Model, type OpenCode2Workspace, } from "./status.ts"; -import { readOpenCodeGoUsageLimits } from "./usageLimits.ts"; +import { readOpenCodeUsageLimits } from "./usageLimits.ts"; import { probeOpenCodeRuntime } from "./versionProbe.ts"; import { OPENCODE_1_RESPONSES, @@ -33,10 +33,170 @@ const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; +it.effect("reports a Tinfoil-only connection as API usage without reading Go credentials", () => + Effect.gen(function* () { + const settings = decodeOpenCodeSettings({ + customModels: [{ slug: "tinfoil/kimi-k3", name: "Private coding" }], + }); + NodeAssert.equal(resolveOpenCodeUsageProvider(settings), "tinfoil"); + NodeAssert.equal( + resolveOpenCodeUsageProvider({ customModels: ["tinfoil/kimi-k3"] }), + "tinfoil", + ); + NodeAssert.equal(resolveOpenCodeUsageProvider({ customModels: [] }), "opencode-go"); + NodeAssert.equal( + resolveOpenCodeUsageProvider({ + customModels: [...settings.customModels, { slug: "opencode/other-model", name: "Other" }], + }), + "opencode-go", + ); + const limits = yield* readOpenCodeUsageLimits({ + enabled: true, + serverUrl: "", + environment: { OPENCODE_API_KEY: "unrelated-go-key" }, + usageProvider: resolveOpenCodeUsageProvider(settings), + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("Tinfoil must not read Go credentials"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Tinfoil local usage must not send a credential to Go")), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.deepEqual(limits.windows, []); + NodeAssert.equal(limits.unavailable?.reason, "unsupported"); + NodeAssert.match(limits.unavailable?.message ?? "", /admin key/); + NodeAssert.deepEqual(limits.externalUsage, { + label: "Tinfoil billing", + url: "https://dash.tinfoil.sh/?tab=billing", + }); + }), +); + +it.effect( + "reads live Tinfoil credits and settled funding without using inference credentials", + () => + Effect.gen(function* () { + const requests: string[] = []; + const limits = yield* readOpenCodeUsageLimits({ + enabled: true, + serverUrl: "", + environment: {}, + usageProvider: "tinfoil", + tinfoilAdminKey: "admin_synthetic_billing_key", + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request.url); + NodeAssert.equal(request.method, "GET"); + NodeAssert.equal(request.headers.authorization, "Bearer admin_synthetic_billing_key"); + const body = request.url.endsWith("/credits/history") + ? { + history: [ + { id: "sample-purchase", amount_dollars: 80, status: "paid" }, + { id: "sample-promo", amount_dollars: 16, status: "granted" }, + { id: "sample-pending", amount_dollars: 140, status: "pending" }, + { id: "sample-failed", amount_dollars: 55, status: "failed" }, + ], + } + : request.url.endsWith("/credits") + ? { balance_dollars: 67.2 } + : { context_type: "personal", user_id: "synthetic-owner", organization: null }; + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body))); + }), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.deepEqual(limits.prepaidBalance, { remainingUsd: 67.2, totalGrantedUsd: 96 }); + NodeAssert.equal(limits.unavailable, undefined); + NodeAssert.equal(requests.length, 3); + NodeAssert.ok(requests.every((url) => url.startsWith("https://api.tinfoil.sh/api/"))); + NodeAssert.ok(limits.credentialFingerprint); + NodeAssert.ok(!JSON.stringify(limits).includes("admin_synthetic_billing_key")); + NodeAssert.ok(!JSON.stringify(limits).includes("synthetic-owner")); + }), +); + +it.effect("keeps a live balance when funding history is incomplete or unavailable", () => + Effect.gen(function* () { + for (const history of [ + { history: [], has_more: true }, + { history: [{ id: "sample-partial", amount_dollars: 40, status: "paid" }] }, + ]) { + const limits = yield* readOpenCodeUsageLimits({ + enabled: true, + serverUrl: "", + environment: {}, + usageProvider: "tinfoil", + tinfoilAdminKey: "admin_synthetic_billing_key", + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + request.url.endsWith("/credits/history") + ? history + : request.url.endsWith("/credits") + ? { balance_dollars: 67.2 } + : { context_type: "personal", user_id: "synthetic", organization: null }, + ), + ), + ), + ), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.deepEqual(limits.prepaidBalance, { remainingUsd: 67.2 }); + } + }), +); + +it.effect("keeps failed Tinfoil billing reads distinct from an exhausted balance", () => + Effect.gen(function* () { + for (const status of [401, 403, 429, 500, 302]) { + const limits = yield* readOpenCodeUsageLimits({ + enabled: true, + serverUrl: "", + environment: {}, + usageProvider: "tinfoil", + tinfoilAdminKey: "admin_synthetic_billing_key", + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { error: "private provider message" }, + { status, headers: { Location: "https://untrusted.invalid/" } }, + ), + ), + ), + ), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.equal(limits.prepaidBalance, undefined); + NodeAssert.equal(limits.unavailable?.reason, "probeFailed"); + NodeAssert.ok(!JSON.stringify(limits).includes("private provider message")); + } + }), +); + it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => Effect.gen(function* () { const resetsAt = "2026-09-17T12:00:00.000Z"; - const limits = yield* readOpenCodeGoUsageLimits({ + const limits = yield* readOpenCodeUsageLimits({ enabled: true, serverUrl: "", environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" }, @@ -94,7 +254,7 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res ); it.effect( - "uses the selected public Console key for Go limits, falling back only on auth rejection", + "uses the selected public Console credential for Go limits, falling back only on auth rejection", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -114,10 +274,23 @@ it.effect( const insert = database.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?, ?)"); const window = { percent: 1, resetsAt: "2026-09-17T12:00:00.000Z" }; const consoleKey = { type: "key", key: "console-key" }; + // OpenCode 2's Console browser sign-in stores a device OAuth grant. + const consoleOAuth = { + type: "oauth", + methodID: "device", + access: "oauth-token", + refresh: "refresh", + expires: 0, + metadata: { server: "https://opencode.ai/console", accountID: "user_test" }, + }; const fingerprints = new Map([ ["console-key", "3e45cfde9158185930d4cc7020a4e390895574fd54bd8f9631d23a336bbf94f0"], ["go-key", "d0e590e93fc9bd9353f2b993c0c287391cae505da2a78cb698b75418d2f5367e"], ["inline-key", "85649de18e63305548b6407e185f708483727ee6189cc4bc9512ddb17e23e776"], + // The OAuth fingerprint tracks the stable account id across token rotation. + ["oauth-token", "6fa4371cfb9db0464a57023cf5e4dd1707f3df0de78b4a7a7e2d300dd3324700"], + ["oauth-rotated", "6fa4371cfb9db0464a57023cf5e4dd1707f3df0de78b4a7a7e2d300dd3324700"], + ["oauth-anonymous", "9becfaa9d19124391ab39d490800d7b66ce645f3b29dba17064f79f26c35bbb2"], ]); for (const { consoleRow = consoleKey, @@ -135,14 +308,18 @@ it.effect( { consoleStatus: 403, requested: ["console-key", "go-key"] }, { consoleStatus: 500, requested: ["console-key"] }, { active: 0, requested: ["go-key"] }, + { consoleRow: consoleOAuth, requested: ["oauth-token"] }, { - consoleRow: { - type: "oauth", - methodID: "device", - access: "token", - refresh: "refresh", - expires: 0, - }, + consoleRow: { ...consoleOAuth, access: "oauth-rotated" }, + requested: ["oauth-rotated"], + }, + { + consoleRow: { ...consoleOAuth, access: "oauth-anonymous", metadata: null }, + requested: ["oauth-anonymous"], + }, + { consoleRow: consoleOAuth, consoleStatus: 401, requested: ["oauth-token", "go-key"] }, + { + consoleRow: { ...consoleOAuth, metadata: { server: "https://console.example" } }, requested: ["go-key"], }, { @@ -177,7 +354,7 @@ it.effect( } const before = yield* fs.readFile(`${dataHome}/opencode/opencode.db`); const authorizations: string[] = []; - const limits = yield* readOpenCodeGoUsageLimits({ + const limits = yield* readOpenCodeUsageLimits({ enabled: true, serverUrl: "", environment: { @@ -196,7 +373,11 @@ it.effect( authorizations.push(key); // The legacy key is expired in the regression; it only works in fallback scenarios. const status = - key === "console-key" ? consoleStatus : requested.includes(key) ? 200 : 401; + key === "console-key" || key === "oauth-token" + ? consoleStatus + : requested.includes(key) + ? 200 + : 401; return Effect.succeed( HttpClientResponse.fromWeb( request, @@ -230,7 +411,7 @@ it.effect("does not read local credentials for external or disabled OpenCode ins { enabled: true, serverUrl: "https://remote.example" }, { enabled: false, serverUrl: "" }, ]) { - const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe( + const limits = yield* readOpenCodeUsageLimits({ ...settings, environment: {} }).pipe( Effect.provideService( FileSystem.FileSystem, FileSystem.makeNoop({ @@ -255,7 +436,7 @@ it.effect("keeps Go entitlement absence distinct from failed or malformed usage [401, "probeFailed"], [200, "probeFailed"], ] as const) { - const limits = yield* readOpenCodeGoUsageLimits({ + const limits = yield* readOpenCodeUsageLimits({ enabled: true, serverUrl: "", environment: { diff --git a/packages/provider-opencode/src/server/tinfoilUsageLimits.ts b/packages/provider-opencode/src/server/tinfoilUsageLimits.ts new file mode 100644 index 0000000000000..519082657707e --- /dev/null +++ b/packages/provider-opencode/src/server/tinfoilUsageLimits.ts @@ -0,0 +1,126 @@ +import type { ServerProviderUsageLimits } from "@t3tools/contracts"; +import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; +import * as Schema from "effect/Schema"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +const Balance = Schema.Struct({ balance_dollars: Schema.Finite }); +const CreditHistory = Schema.Struct({ + history: Schema.Array( + Schema.Struct({ + id: Schema.String, + amount_dollars: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)), + status: Schema.Literals(["paid", "granted", "pending", "failed"]), + }), + ), + has_more: Schema.optionalKey(Schema.Boolean), + next_cursor: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); +const Caller = Schema.Struct({ + context_type: Schema.Literals(["personal", "organization"]), + user_id: Schema.String, + organization: Schema.NullOr(Schema.Struct({ id: Schema.String })), +}); + +export const readTinfoilUsageLimits = Effect.fn("readTinfoilUsageLimits")(function* (input: { + readonly checkedAt: string; + readonly adminKey: string; +}) { + const externalUsage = { label: "Tinfoil billing", url: "https://dash.tinfoil.sh/?tab=billing" }; + const unavailable = ( + message: string, + reason: "unsupported" | "probeFailed" = "probeFailed", + ): ServerProviderUsageLimits => ({ + ...makeUnavailableUsageLimits({ checkedAt: input.checkedAt, reason, message }), + externalUsage, + }); + const key = input.adminKey.trim(); + if (!key) + return unavailable( + "Connect a Tinfoil billing admin key to read your account balance automatically.", + "unsupported", + ); + if (!key.startsWith("admin_") || /\s/.test(key)) + return unavailable( + "Tinfoil billing requires an admin key. Inference keys cannot read account credits.", + ); + let failureMessage = "Tinfoil billing could not be refreshed. Try again."; + return yield* Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const get = (path: string) => + client.execute( + HttpClientRequest.get(`https://api.tinfoil.sh${path}`).pipe( + HttpClientRequest.bearerToken(key), + ), + ); + const response = yield* get("/api/billing/credits"); + if (response.status === 401) + failureMessage = "The Tinfoil billing key was rejected. Update the admin key."; + if (response.status === 403) + failureMessage = + "This Tinfoil key cannot read billing. Use an unscoped admin key for this account."; + if (response.status === 429) + failureMessage = "Tinfoil billing is rate limited. Refresh again shortly."; + const balance = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(Balance)), + ); + const [history, caller] = yield* Effect.all( + [ + get("/api/billing/credits/history").pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(CreditHistory)), + Effect.orElseSucceed(() => undefined), + ), + get("/api/auth/context").pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(Caller)), + Effect.orElseSucceed(() => undefined), + ), + ], + { concurrency: "unbounded" }, + ); + // The dashboard ledger marks settled purchases paid and promotional credits granted. + // Pending top-ups and failed charges contribute no funding to the remaining percentage. + const totalGrantedUsd = + history && !history.has_more && !history.next_cursor + ? [...new Map(history.history.map((entry) => [entry.id, entry])).values()].reduce( + (sum, entry) => + sum + + (entry.status === "paid" || entry.status === "granted" ? entry.amount_dollars : 0), + 0, + ) + : undefined; + const owner = + caller?.context_type === "organization" ? caller.organization?.id : caller?.user_id; + const crypto = yield* Crypto.Crypto; + const credentialFingerprint = owner + ? yield* crypto + .digest("SHA-256", new TextEncoder().encode(`tinfoil\0${caller!.context_type}:${owner}`)) + .pipe(Effect.map(Hex.encode), Effect.orDie) + : undefined; + const limits: ServerProviderUsageLimits = { + checkedAt: input.checkedAt, + windows: [], + externalUsage, + prepaidBalance: { + remainingUsd: balance.balance_dollars, + ...(totalGrantedUsd !== undefined && + totalGrantedUsd > 0 && + totalGrantedUsd >= balance.balance_dollars + ? { totalGrantedUsd } + : {}), + }, + ...(credentialFingerprint ? { credentialFingerprint } : {}), + }; + return limits; + }).pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => unavailable(failureMessage)), + ); +}); diff --git a/packages/provider-opencode/src/server/usage.test.ts b/packages/provider-opencode/src/server/usage.test.ts index 20a768609a7da..94eb7f47adc96 100644 --- a/packages/provider-opencode/src/server/usage.test.ts +++ b/packages/provider-opencode/src/server/usage.test.ts @@ -263,6 +263,11 @@ describe("openCodeUsageReader", () => { environment: { XDG_DATA_HOME: dataHome }, configured: true, }, + { + id: "opencode-shared", + environment: { OPENCODE_DATA_DIR: instanceRoot }, + configured: true, + }, ]; if (openCodeUsageReader.kind !== "scan") throw new Error("Expected an OpenCode scan reader"); const sources = yield* openCodeUsageReader @@ -286,6 +291,18 @@ describe("openCodeUsageReader", () => { sources.map((source) => source.dir).sort(), [defaultRoot, instanceRoot, xdgRoot].sort(), ); + assert.deepStrictEqual( + sources.find((source) => source.dir === defaultRoot)?.providerInstanceIds, + [ProviderInstanceId.make("opencode-list")], + ); + assert.deepStrictEqual( + sources.find((source) => source.dir === instanceRoot)?.providerInstanceIds, + [ProviderInstanceId.make("opencode-list"), ProviderInstanceId.make("opencode-shared")], + ); + assert.deepStrictEqual( + sources.find((source) => source.dir === xdgRoot)?.providerInstanceIds, + [ProviderInstanceId.make("opencode-xdg")], + ); }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/packages/provider-opencode/src/server/usage.ts b/packages/provider-opencode/src/server/usage.ts index 622b441fe8904..28496f682da05 100644 --- a/packages/provider-opencode/src/server/usage.ts +++ b/packages/provider-opencode/src/server/usage.ts @@ -1,3 +1,4 @@ +import type { ProviderInstanceId } from "@t3tools/contracts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { totalTokens, @@ -335,16 +336,21 @@ export const openCodeUsageReader: ProviderUsageReader(); + const roots = new Map>(); for (const instance of instances) { - for (const root of yield* resolveOpenCodeDataDirs(instance.environment)) roots.add(root); + for (const root of yield* resolveOpenCodeDataDirs(instance.environment)) { + const bindings = roots.get(root) ?? new Set(); + if (instance.configured) bindings.add(instance.instanceId); + roots.set(root, bindings); + } } return yield* Effect.forEach( roots, - (dir) => + ([dir, bindings]) => readOpenCodeUsage(dir, windowStartMs).pipe( Effect.map((result) => ({ dir, + providerInstanceIds: [...bindings], files: result.missing && !result.error ? null : result.files, status: result.error ? ("partial" as const) : ("ok" as const), ...(result.error ? { message: "Some OpenCode history could not be read." } : {}), diff --git a/packages/provider-opencode/src/server/usageLimits.ts b/packages/provider-opencode/src/server/usageLimits.ts index a477ae83b9a6a..98754d0bf5076 100644 --- a/packages/provider-opencode/src/server/usageLimits.ts +++ b/packages/provider-opencode/src/server/usageLimits.ts @@ -19,20 +19,32 @@ import { makeUsageLimits, } from "@t3tools/provider-core/server/usageLimits"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { readTinfoilUsageLimits } from "./tinfoilUsageLimits.ts"; const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); // OpenCode 2's database tags a stored key "key"; auth.json tags it "api". +const StoredMetadata = Schema.Struct({ + server: Schema.optionalKey(Schema.Unknown), + accountID: Schema.optionalKey(Schema.Unknown), +}); const StoredKey = Schema.Struct({ type: Schema.Literal("key"), key: Schema.String, - metadata: Schema.optionalKey( - Schema.NullOr(Schema.Struct({ server: Schema.optionalKey(Schema.Unknown) })), - ), + metadata: Schema.optionalKey(Schema.NullOr(StoredMetadata)), +}); +// A Console sign-in stores a device OAuth grant. Its access token reads the +// same Go usage endpoint as an API key; OpenCode itself rotates it on a 401. +const StoredOAuth = Schema.Struct({ + type: Schema.Literal("oauth"), + access: Schema.String, + metadata: Schema.optionalKey(Schema.NullOr(StoredMetadata)), }); -const decodeStoredKey = Schema.decodeOption(Schema.fromJsonString(StoredKey)); +const decodeStoredCredential = Schema.decodeOption( + Schema.fromJsonString(Schema.Union([StoredKey, StoredOAuth])), +); const UsageWindow = Schema.Struct({ percent: Schema.Finite, resetsAt: Schema.DateTimeUtcFromString, @@ -41,9 +53,9 @@ const UsageResponse = Schema.Struct({ usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), }); -// Select before decoding so an OAuth or self-hosted connection cannot fall -// through to another stored account. NULL-active rows predate the selection flag. -const readConsoleApiKey = (databasePath: string) => +// Selecting before decoding preserves the selected account for unsupported +// credential types and self-hosted connections. NULL-active rows predate the selection flag. +const readConsoleCredential = (databasePath: string) => Effect.try(() => { const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); try { @@ -57,7 +69,7 @@ const readConsoleApiKey = (databasePath: string) => .get(); const stored = typeof row?.value === "string" - ? Option.getOrUndefined(decodeStoredKey(row.value)) + ? Option.getOrUndefined(decodeStoredCredential(row.value)) : undefined; const server = stored?.metadata?.server; if (typeof server === "string") { @@ -65,20 +77,35 @@ const readConsoleApiKey = (databasePath: string) => if (`${url.origin}${url.pathname.replace(/\/+$/, "")}` !== "https://opencode.ai/console") return undefined; } - return stored?.key.trim(); + if (stored?.type === "oauth") { + const key = stored.access.trim(); + if (!key) return undefined; + const accountId = stored.metadata?.accountID; + return { + key, + ...(typeof accountId === "string" && accountId.trim() ? { accountId } : {}), + }; + } + const key = stored?.key.trim(); + return key ? { key } : undefined; } finally { database.close(); } }).pipe(Effect.orElseSucceed(() => undefined)); /** External OpenCode servers own their credentials; never read the host's account for them. */ -export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { +export const readOpenCodeUsageLimits = Effect.fn("readOpenCodeUsageLimits")(function* (input: { readonly enabled: boolean; readonly serverUrl: string; readonly environment: NodeJS.ProcessEnv; + readonly usageProvider?: "tinfoil" | "opencode-go"; + readonly tinfoilAdminKey?: string; }) { const checkedAt = DateTime.formatIso(yield* DateTime.now); const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + if (input.enabled && input.usageProvider === "tinfoil") { + return yield* readTinfoilUsageLimits({ checkedAt, adminKey: input.tinfoilAdminKey ?? "" }); + } if (!input.enabled || input.serverUrl.trim()) return unsupported; return yield* Effect.gen(function* () { @@ -108,11 +135,12 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( // Console supplies Go credentials in OpenCode 2. Inline auth explicitly // overrides local credentials, including the Console connection. const databaseFile = env.OPENCODE_DB ?? "opencode.db"; - const consoleKey = + const consoleCredential = env.OPENCODE_AUTH_CONTENT || databaseFile === ":memory:" ? undefined - : yield* readConsoleApiKey(path.resolve(dataHome, "opencode", databaseFile)); - let apiKey = consoleKey || goKey; + : yield* readConsoleCredential(path.resolve(dataHome, "opencode", databaseFile)); + let accountId = consoleCredential?.accountId; + let apiKey = consoleCredential?.key || goKey; if (!apiKey) return unsupported; const client = yield* HttpClient.HttpClient; @@ -124,6 +152,7 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( ); let response = yield* readUsage(apiKey); if ((response.status === 401 || response.status === 403) && goKey && goKey !== apiKey) { + accountId = undefined; apiKey = goKey; response = yield* readUsage(apiKey); } @@ -159,9 +188,16 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")( ]; const crypto = yield* Crypto.Crypto; // An unkeyed hash matches across environments without a shared secret. It - // permits offline guesses, but Go keys are randomly generated. + // permits offline guesses, but Go keys are randomly generated. OAuth + // credentials hash the stable account id so token rotation cannot split + // one account into several panel rows. const credentialFingerprint = yield* crypto - .digest("SHA-256", new TextEncoder().encode(`opencode-go\0${apiKey}`)) + .digest( + "SHA-256", + new TextEncoder().encode( + accountId ? `opencode-go\0oauth:${accountId}` : `opencode-go\0${apiKey}`, + ), + ) .pipe(Effect.map(Hex.encode), Effect.orDie); return { ...makeUsageLimits({ checkedAt, windows }), diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index f837e24e2793e..5ff85bccf9f81 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -54,6 +54,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { canAutoApprove } from "./autoApproval.ts"; import * as OpenCode2Client from "./OpenCode2Client.ts"; import * as OpenCode2Server from "./OpenCode2Server.ts"; import * as OpenCodeRuntime from "../OpenCodeRuntime.ts"; @@ -221,6 +222,9 @@ interface ActiveTurn { readonly startedAt: DateTime.Utc; } | undefined; + capacityAttempts: number; + capacitySequence: number; + capacityWaiting: boolean; interrupted: boolean; unsent: boolean; readonly steers: Set; @@ -1151,6 +1155,9 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr compaction: undefined, compactions: 0, retry: undefined, + capacityAttempts: 0, + capacitySequence: 0, + capacityWaiting: false, interrupted: false, unsent: false, steers: new Set(), @@ -1919,7 +1926,31 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }); const onPermissionAsked = Effect.fnUntraced(function* (data: Permission.Request) { - const turn = threads.get(data.sessionID)?.active; + const state = threads.get(data.sessionID); + const turn = state?.active; + const tool = data.source === undefined ? undefined : turn?.tools.get(data.source.id); + if ( + state?.policy.runtimeMode === "auto" && + turn !== undefined && + !turn.interrupted && + (yield* canAutoApprove(fileSystem, { + action: data.action, + resources: data.resources, + directory: state.directory, + command: tool?.input.command, + workdir: tool?.input.workdir, + plan: state.policy.interactionMode === "plan", + })) + ) { + const accepted = yield* deliver( + client.permission.reply({ + sessionID: Session.ID.make(data.sessionID), + requestID: data.id, + decision: "once", + }), + ); + if (accepted) return; + } const toolName = data.source === undefined ? undefined : turn?.tools.get(data.source.id)?.name; const save = data.save ?? []; @@ -2068,6 +2099,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr ) { yield* emitRetry(state, turn, "completed"); turn.retry = undefined; + turn.capacityAttempts = 0; } switch (event.type) { case "session.text.started": @@ -2184,6 +2216,72 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return yield* endExecution(state, turn, { status: "interrupted" }); case "session.execution.failed": { const { type, message, status } = event.data.error; + const capacity = + /(?:model.{0,40}(?:at capacity|overloaded)|(?:at capacity|overloaded).{0,40}model)/i.test( + message, + ) && + status !== 401 && + status !== 403 && + status !== 402 && + type !== "provider.quota"; + if (capacity && !turn.interrupted && state.subagent === undefined) { + if (turn.capacityWaiting) return; + turn.capacityWaiting = true; + const attempt = ++turn.capacityAttempts; + const sequence = ++turn.capacitySequence; + const delay = [5000, 5000, 10000, 10000, 30000, 30000][attempt - 1] ?? 60000; + yield* Effect.gen(function* () { + yield* Effect.sleep(delay); + yield* lock.withPermit( + Effect.gen(function* () { + if (state.active !== turn || turn.interrupted || !turn.capacityWaiting) return; + const current = yield* ( + host.canRetryTurn?.(turn.input.threadId, turn.input.modelSelection) ?? + Effect.succeed(false) + ); + if (turn.interrupted || state.active !== turn) return; + if (!current) { + turn.capacityWaiting = false; + return yield* finishTurn(state, { status: "interrupted" }); + } + // The failed execution has ended. A synthetic continuation preserves + // tool results and resumes the conversation without replaying its prompt. + turn.capacityWaiting = false; + const sent = yield* deliver( + client.session + .synthetic({ + sessionID: Session.ID.make(state.sessionId), + text: "Continue from the last completed step after the temporary model capacity error.", + resume: true, + id: steerPromptId( + state.sessionId, + `${turn.providerTurn.id}:capacity:${sequence}`, + ), + }) + .pipe(Effect.asVoid), + ); + turn.capacityWaiting = false; + if (!sent) { + markUnsettled(state); + yield* finishTurn(state, { + status: "failed", + failure: makeProviderFailure({ + message: "Could not resume OpenCode after the model capacity error.", + class: "transport_error", + }), + }); + } + }), + ); + }).pipe(Effect.forkIn(sessionScope)); + yield* updateRetry( + state, + turn, + { attempt: attempt + 1, error: event.data.error }, + delay, + ); + return; + } // OpenCode types an empty balance as provider.quota too, but it answers // that with HTTP 402 and it never resets. Usage windows and budgets are 429. const usageLimit = @@ -2628,7 +2726,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr for (const childId of strays.keys()) if (!(childId in active)) strays.delete(childId); for (const [sessionId, state] of running) { const turn = state.active; - if (turn === undefined) continue; + if (turn === undefined || turn.capacityWaiting) continue; const ended = yield* backfill(sessionId, state); if (sessionId in active || state.active !== turn) continue; yield* finishTurn( @@ -3825,6 +3923,20 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr return; } turn.interrupted = true; + if (turn.capacityAttempts > 0) { + // Serialize with admission of a synthetic continuation: once sent, + // Stop must interrupt the native execution as well as its timer. + const cancelled = yield* lock.withPermit( + Effect.gen(function* () { + if (state.active !== turn) return true; + if (!turn.capacityWaiting) return false; + turn.capacityWaiting = false; + yield* finishTurn(state, { status: "interrupted" }); + return true; + }), + ); + if (cancelled) return; + } const reply = yield* client.session .interrupt({ sessionID: Session.ID.make(sessionId) }) .pipe( diff --git a/packages/provider-opencode/src/server/v2/autoApproval.test.ts b/packages/provider-opencode/src/server/v2/autoApproval.test.ts new file mode 100644 index 0000000000000..c3f9e38f2632f --- /dev/null +++ b/packages/provider-opencode/src/server/v2/autoApproval.test.ts @@ -0,0 +1,90 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as NodePath from "node:path"; +import { canAutoApprove } from "./autoApproval.ts"; + +const fs = FileSystem.makeNoop({ realPath: (path) => Effect.succeed(NodePath.resolve(path)) }); +const shell = (command: string) => ({ + action: "shell", + resources: [command], + command, + directory: "/work/project", + plan: false, +}); + +it.effect.each([ + "pwd", + 'ls -la "/work/project/src"', + "cat src/main.ts", + "head -n 100 README.md", + "tail -n 40 src/main.ts", +])("approves literal workspace read: %s", (command) => + Effect.gen(function* () { + assert.isTrue(yield* canAutoApprove(fs, shell(command))); + }), +); + +it.effect.each([ + "cat .env", + "cat .env.local", + "cat .t3-accounts/opencode/auth.json", + "cat ../secret", + "cat /etc/passwd", + "cat .ssh/id_rsa", + "cat /work/project-other/readme", + "ls; curl example.com", + "ls $(touch x)", + "ls `touch x`", + "ls > file", + "ls | sh", + "ls && rm -rf src", + "cat <(curl example.com)", + "cat *.ts", + "cat $HOME/file", + "ls -R", + "tail -f src/main.ts", + 'node -e "code"', + "git diff --ext-diff", + "rg --pre executable pattern", + 'sed -n "1e command" file', + 'cat "unterminated', +])("asks for protected paths or unsupported shell syntax: %s", (command) => + Effect.gen(function* () { + assert.isFalse(yield* canAutoApprove(fs, shell(command))); + }), +); + +it.effect("checks full tool input, working directory, symlink targets, and plan mode", () => + Effect.gen(function* () { + assert.isFalse( + yield* canAutoApprove(fs, { ...shell("ls"), command: "ls && curl example.com" }), + ); + assert.isFalse(yield* canAutoApprove(fs, { ...shell("ls"), workdir: "/outside" })); + assert.isFalse(yield* canAutoApprove(fs, { ...shell("ls"), command: undefined })); + assert.isFalse(yield* canAutoApprove(fs, { ...shell("ls"), action: "external_directory" })); + const edit = { + action: "edit", + resources: ["/work/project/src/main.ts"], + directory: "/work/project", + plan: false, + }; + assert.isTrue(yield* canAutoApprove(fs, edit)); + assert.isFalse(yield* canAutoApprove(fs, { ...edit, plan: true })); + assert.isFalse(yield* canAutoApprove(fs, { ...edit, resources: ["/work/project/AGENTS.md"] })); + const links = FileSystem.makeNoop({ + realPath: (path) => Effect.succeed(path.includes("/src/") ? "/outside/file" : path), + }); + assert.isFalse(yield* canAutoApprove(links, edit)); + assert.isFalse(yield* canAutoApprove(FileSystem.makeNoop({}), edit)); + }), +); + +it.effect.each(["cat .en[v]", 'cat se"cret"', 'cat "se"cret', "cat '.en'v"])( + "asks for shell word concatenation or bracket expansion: %s", + (command) => + Effect.gen(function* () { + assert.isFalse(yield* canAutoApprove(fs, shell(command))); + }), +); diff --git a/packages/provider-opencode/src/server/v2/autoApproval.ts b/packages/provider-opencode/src/server/v2/autoApproval.ts new file mode 100644 index 0000000000000..9d12a5f5c3ca0 --- /dev/null +++ b/packages/provider-opencode/src/server/v2/autoApproval.ts @@ -0,0 +1,125 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; + +// Auto is deliberately a small deterministic policy. Unrecognized syntax, +// missing paths and filesystem errors fall back to the normal approval UI. +const protectedPath = (path: string) => + path + .split(/[\\/]/) + .some( + (part) => + /^(?:\.env(?:\..*)?|\.git|\.ssh|\.aws|\.azure|\.npmrc|\.netrc|\.pypirc|\.docker|\.gnupg|\.kube|\.config|\.codex|\.claude|\.agents|\.opencode|\.t3.*|auth\.json|credentials.*|secrets?(?:\..*)?|.*\.(?:pem|key|p12|pfx))$/i.test( + part, + ) && part !== ".env.example", + ); +const inside = (root: string, path: string) => { + const relative = NodePath.relative(root, path); + return ( + relative === "" || + (!relative.startsWith(`..${NodePath.sep}`) && + relative !== ".." && + !NodePath.isAbsolute(relative)) + ); +}; + +// Reject all shell expansion, chaining, redirection and escapes, including inside +// quotes. This accepts only a single command with literal arguments. +function words(command: string): string[] | undefined { + // Control characters and expansion operators require explicit approval. + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f$`\\;&|<>(){}*?!~[\]]/.test(command)) return undefined; + const tokens: string[] = []; + let end = 0; + for (const match of command.matchAll(/"[^"\n]*"|'[^'\n]*'|[^\s"']+/g)) { + const gap = command.slice(end, match.index); + // Shell concatenates adjacent quoted fragments. Require whitespace so our + // literal arguments cannot differ from the arguments the shell executes. + if (!(tokens.length === 0 ? /^\s*$/ : /^\s+$/).test(gap)) return undefined; + const token = match[0]; + tokens.push(/^["']/.test(token) ? token.slice(1, -1) : token); + end = match.index + token.length; + } + return tokens.length > 0 && /^\s*$/.test(command.slice(end)) ? tokens : undefined; +} + +function readPaths(command: string): string[] | undefined { + const tokens = words(command); + if (!tokens) return undefined; + const [name, ...args] = tokens; + if (name === "pwd") return args.length === 0 ? ["."] : undefined; + if (!["ls", "cat", "head", "tail"].includes(name ?? "")) return undefined; + const paths: string[] = []; + let positional = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i]!; + if (!positional && arg === "--") { + positional = true; + continue; + } + if (!positional && arg.startsWith("-")) { + if (name === "ls" && /^-[alhdtF1]+$/.test(arg)) continue; + if (name === "cat" && /^-[nbEsTv]+$/.test(arg)) continue; + if ( + (name === "head" || name === "tail") && + arg === "-n" && + /^\d{1,5}$/.test(args[i + 1] ?? "") + ) { + i++; + continue; + } + return undefined; + } + if (arg === "" || arg === "-") return undefined; + paths.push(arg); + } + return paths.length > 0 ? paths : name === "ls" ? ["."] : undefined; +} + +export const canAutoApprove = Effect.fn("OpenCode2.autoApproval")(function* ( + fs: FileSystem.FileSystem, + input: { + readonly action: string; + readonly resources: readonly string[]; + readonly directory: string; + readonly command?: unknown; + readonly workdir?: unknown; + readonly plan: boolean; + }, +) { + if (input.resources.length === 0) return false; + let paths: readonly string[]; + if (input.action === "edit" && !input.plan) paths = input.resources; + else if (input.action === "shell" && typeof input.command === "string") { + const parsed = readPaths(input.command); + // Require both the full tool input and every native scanner resource to + // satisfy the policy. Never approve just a harmless fragment of a pipeline. + const scanned = input.resources.map(readPaths); + if (!parsed || scanned.some((resource) => resource === undefined)) return false; + paths = [...parsed, ...scanned.flatMap((resource) => resource ?? [])]; + } else return false; + const cwd = typeof input.workdir === "string" ? input.workdir : input.directory; + const root = yield* fs.realPath(input.directory).pipe(Effect.option); + if (root._tag === "None") return false; + const directory = yield* fs.realPath(cwd).pipe(Effect.option); + if (directory._tag === "None" || !inside(root.value, directory.value)) return false; + for (const path of paths) { + // Keep symlink/.. semantics intact until realPath resolves the filesystem. + const absolute = NodePath.isAbsolute(path) ? path : `${cwd}/${path}`; + if (protectedPath(absolute)) return false; + const canonical = yield* fs.realPath(absolute).pipe(Effect.option); + if ( + canonical._tag === "None" || + !inside(root.value, canonical.value) || + protectedPath(canonical.value) + ) + return false; + if ( + input.action === "edit" && + /(?:^|\/)(?:AGENTS\.md|SKILL\.md|opencode\.jsonc?)$/i.test(canonical.value) + ) + return false; + } + return true; +}); diff --git a/packages/provider-opencode/src/server/v2/scopedText.test.ts b/packages/provider-opencode/src/server/v2/scopedText.test.ts new file mode 100644 index 0000000000000..9f7b2d1ebe89a --- /dev/null +++ b/packages/provider-opencode/src/server/v2/scopedText.test.ts @@ -0,0 +1,81 @@ +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as OpenCode2Server from "./OpenCode2Server.ts"; +import * as ScopedText from "./scopedText.ts"; + +it.effect("uses only the stateless prompt/model endpoint, with no session or workspace", () => + Effect.gen(function* () { + const requests: unknown[] = []; + const connection = { + external: false, + client: { + generate: { + text: (input: unknown) => + Effect.sync(() => { + requests.push(input); + return { text: "synthetic artifact" }; + }), + }, + }, + } as unknown as OpenCode2Server.OpenCode2Connection; + const generate = yield* ScopedText.make.pipe( + Effect.provide( + Layer.mock(OpenCode2Server.OpenCode2Server)({ withConnection: (use) => use(connection) }), + ), + ); + const modelSelection = { + instanceId: ProviderInstanceId.make("synthetic-go"), + model: "opencode-go/synthetic-coder", + }; + expect(yield* generate({ prompt: "Only invented inputs", modelSelection })).toEqual({ + text: "synthetic artifact", + }); + expect(requests).toEqual([ + { + prompt: "Only invented inputs", + model: { providerID: "opencode-go", id: "synthetic-coder" }, + }, + ]); + }), +); + +it.effect("blocks remote servers and unsupported agent options before sending", () => + Effect.gen(function* () { + let calls = 0; + const connection = { + external: true, + client: { + generate: { + text: () => + Effect.sync(() => { + calls++; + return { text: "unreachable" }; + }), + }, + }, + } as unknown as OpenCode2Server.OpenCode2Connection; + const generate = yield* ScopedText.make.pipe( + Effect.provide( + Layer.mock(OpenCode2Server.OpenCode2Server)({ withConnection: (use) => use(connection) }), + ), + ); + const modelSelection = { + instanceId: ProviderInstanceId.make("synthetic-go"), + model: "opencode-go/synthetic-coder", + }; + expect((yield* Effect.result(generate({ prompt: "Invented", modelSelection })))._tag).toBe( + "Failure", + ); + expect( + (yield* Effect.result( + generate({ + prompt: "Invented", + modelSelection: { ...modelSelection, options: [{ id: "agent", value: "build" }] }, + }), + ))._tag, + ).toBe("Failure"); + expect(calls).toBe(0); + }), +); diff --git a/packages/provider-opencode/src/server/v2/scopedText.ts b/packages/provider-opencode/src/server/v2/scopedText.ts new file mode 100644 index 0000000000000..ff3f196f2c3fc --- /dev/null +++ b/packages/provider-opencode/src/server/v2/scopedText.ts @@ -0,0 +1,43 @@ +import { Model, Provider } from "@opencode/client/effect"; +import { type ModelSelection, PrivateHelperError } from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import * as Effect from "effect/Effect"; +import * as OpenCode2Server from "./OpenCode2Server.ts"; +import { parseOpenCodeModelSlug } from "../OpenCodeRuntime.ts"; + +export const make = Effect.gen(function* () { + const server = yield* OpenCode2Server.OpenCode2Server; + return Effect.fn("OpenCodeScopedText.generate")(function* (input: { + readonly modelSelection: ModelSelection; + readonly prompt: string; + }) { + const parsed = parseOpenCodeModelSlug(input.modelSelection.model); + if ( + parsed === null || + (input.modelSelection.options ?? []).some((option) => option.id !== "variant") + ) + return yield* new PrivateHelperError({ reason: "unavailable" }); + const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + return yield* server + .withConnection((connection) => + Effect.gen(function* () { + if (connection.external) return yield* new PrivateHelperError({ reason: "unavailable" }); + // generate.text accepts only a prompt and model. It creates no agent session or tool loop. + return yield* connection.client.generate + .text({ + prompt: input.prompt, + model: Model.Ref.make({ + providerID: Provider.ID.make(parsed.providerID), + id: Model.ID.make(parsed.modelID), + ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }), + }), + }) + .pipe(Effect.mapError(() => new PrivateHelperError({ reason: "execution-failed" }))); + }), + ) + .pipe( + Effect.timeout("3 minutes"), + Effect.mapError(() => new PrivateHelperError({ reason: "execution-failed" })), + ); + }); +}); diff --git a/packages/provider-opencode/src/settings.ts b/packages/provider-opencode/src/settings.ts index 9b1c267f1038a..9ce5e91bf0132 100644 --- a/packages/provider-opencode/src/settings.ts +++ b/packages/provider-opencode/src/settings.ts @@ -18,6 +18,10 @@ export const OpenCodeSettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), + privateInference: Schema.Literals(["off", "tinfoil"]).pipe( + Schema.withDecodingDefault(Effect.succeed("off")), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), binaryPath: makeBinaryPathSetting("opencode").pipe( Schema.annotateKey({ title: "Binary path", @@ -56,9 +60,35 @@ export const OpenCodeSettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), + tinfoilAdminKey: TrimmedString.pipe( + secretSetting, + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Tinfoil billing admin key", + description: + "Reads this Tinfoil account’s balance and credits. Stored separately on the host.", + providerSettingsForm: { + control: "password", + placeholder: "admin_…", + clearWhenEmpty: "omit", + }, + }), + ), }, { order: ["binaryPath", "serverUrl", "serverPassword"], }, ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; + +/** Only explicit upstream model IDs select API billing; account aliases carry no authority. */ +export function resolveOpenCodeUsageProvider( + settings: Pick, +): "tinfoil" | "opencode-go" { + return settings.customModels.length > 0 && + settings.customModels.every((model) => + (typeof model === "string" ? model : model.slug).startsWith("tinfoil/"), + ) + ? "tinfoil" + : "opencode-go"; +} diff --git a/packages/provider-testing/src/TestProviderHost.ts b/packages/provider-testing/src/TestProviderHost.ts index cd2cbbfe0af95..88c08720b3b03 100644 --- a/packages/provider-testing/src/TestProviderHost.ts +++ b/packages/provider-testing/src/TestProviderHost.ts @@ -56,6 +56,7 @@ export const layer = ( changes: Stream.empty, subscribe: Effect.succeed(Stream.empty), }, + canRetryTurn: () => Effect.succeed(true), shouldRunBackgroundWork: () => Effect.succeed(options.runBackgroundWork ?? true), resolveAttachmentPath: (attachment) => path.join(attachmentsDir, attachment.id), credentials: (namespace, bindingId) => diff --git a/packages/shared/package.json b/packages/shared/package.json index dae5262d5dacc..ea8e9530d1c8b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./providerConfigurationHash": { + "types": "./src/providerConfigurationHash.ts", + "import": "./src/providerConfigurationHash.ts" + }, "./changedFileKind": { "types": "./src/changedFileKind.ts", "import": "./src/changedFileKind.ts" diff --git a/packages/shared/src/orchestrationV2ThreadError.test.ts b/packages/shared/src/orchestrationV2ThreadError.test.ts new file mode 100644 index 0000000000000..f8baa366d3916 --- /dev/null +++ b/packages/shared/src/orchestrationV2ThreadError.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as DateTime from "effect/DateTime"; +import { providerRetryCountdown } from "./orchestrationV2ThreadError.ts"; + +describe("providerRetryCountdown", () => { + const retry = { + status: "running", + updatedAt: DateTime.makeUnsafe(1000), + retry: { attempt: 2, maxAttempts: null, retryDelayMs: 5000 }, + } as const; + it("counts down from this attempt's update and shows when it is sending", () => { + expect(providerRetryCountdown(retry, 1000)).toBe("Retrying in 5s · attempt 2"); + expect(providerRetryCountdown(retry, 4001)).toBe("Retrying in 2s · attempt 2"); + expect(providerRetryCountdown(retry, 6000)).toBe("Retrying now · attempt 2"); + expect(providerRetryCountdown(retry, 9000)).toBe("Retrying now · attempt 2"); + expect( + providerRetryCountdown( + { + ...retry, + updatedAt: DateTime.makeUnsafe(9000), + retry: { ...retry.retry, attempt: 3, retryDelayMs: 10000 }, + }, + 9000, + ), + ).toBe("Retrying in 10s · attempt 3"); + }); + it("clears on success or cancellation", () => { + expect(providerRetryCountdown({ ...retry, status: "completed" }, 1000)).toBeNull(); + expect(providerRetryCountdown({ ...retry, status: "interrupted" }, 1000)).toBeNull(); + expect(providerRetryCountdown({ ...retry, retry: undefined }, 1000)).toBeNull(); + }); +}); diff --git a/packages/shared/src/orchestrationV2ThreadError.ts b/packages/shared/src/orchestrationV2ThreadError.ts index 740d00881b737..a670adad56bbf 100644 --- a/packages/shared/src/orchestrationV2ThreadError.ts +++ b/packages/shared/src/orchestrationV2ThreadError.ts @@ -116,3 +116,18 @@ export function latestUnheldRun( } return latest; } + +/** Each retry's updatedAt anchors its countdown; startedAt belongs to the whole outage. */ +export function providerRetryCountdown( + item: Pick & { readonly retry?: ErrorTurnItem["retry"] }, + nowMs: number, +): string | null { + if (item.status !== "running" || item.retry === undefined) return null; + const delay = item.retry.retryDelayMs; + const remaining = + delay === null + ? 0 + : Math.max(0, Math.min(delay, DateTime.toEpochMillis(item.updatedAt) + delay - nowMs)); + const countdown = remaining > 0 ? `Retrying in ${Math.ceil(remaining / 1000)}s` : "Retrying now"; + return `${countdown} · attempt ${item.retry.attempt}`; +} diff --git a/packages/shared/src/providerConfigurationHash.ts b/packages/shared/src/providerConfigurationHash.ts new file mode 100644 index 0000000000000..ef8729f0f1ddd --- /dev/null +++ b/packages/shared/src/providerConfigurationHash.ts @@ -0,0 +1,38 @@ +import { sha256 } from "@noble/hashes/sha2"; +import * as Hex from "effect/encoding/Hex"; + +function canonicalValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalValue); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, item]) => [key, canonicalValue(item)]), + ); + } + return value; +} + +export function providerConfigurationHash(configuration: unknown): string { + return Hex.encode( + sha256(new TextEncoder().encode(JSON.stringify(canonicalValue(configuration)))), + ); +} + +export function privateInferenceConfigurationHash(configuration: unknown): string { + if ( + configuration !== null && + typeof configuration === "object" && + "config" in configuration && + configuration.config !== null && + typeof configuration.config === "object" + ) { + // Billing authorization never changes where inference is sent. + const { tinfoilAdminKey: _billing, ...inference } = configuration.config as Record< + string, + unknown + >; + return providerConfigurationHash({ ...configuration, config: inference }); + } + return providerConfigurationHash(configuration); +} diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 786126998c96f..7f77809ae8a3d 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -273,12 +273,17 @@ function mergeProjectSettingsOverridesPatch( const next = new Map(Object.entries(current)); for (const [projectId, entry] of Object.entries(patch)) { if (entry === null) { - next.delete(projectId); + if (current[projectId]?.modelPrivacy === "private") + next.set(projectId, { modelPrivacy: "private" }); + else next.delete(projectId); continue; } const storedProviderInstances = current[projectId]?.providerInstances; next.set(projectId, { ...entry, + ...(entry.modelPrivacy === undefined && current[projectId]?.modelPrivacy !== undefined + ? { modelPrivacy: current[projectId]!.modelPrivacy } + : {}), ...(Object.hasOwn(entry, "providerInstances") || storedProviderInstances === undefined ? {} : { providerInstances: storedProviderInstances }), @@ -402,6 +407,7 @@ export function applyServerSettingsPatch( usageModelAliases: usageModelAliasesPatch, // Entry replacement: deepMerge would keep keys the client meant to clear. projectSettingsOverrides: projectSettingsOverridesPatch, + privateModelRoute: privateModelRoutePatch, projectAgentBrowserAccessOverrides: _legacyBrowserAccess, projectAutoPullOverrides: _legacyAutoPull, projectScriptOverrides: _legacyScripts, @@ -491,6 +497,7 @@ export function applyServerSettingsPatch( const next = deepMerge(current, patchForMerge); const nextWithReplacementsBase = { ...next, + ...(privateModelRoutePatch === undefined ? {} : { privateModelRoute: privateModelRoutePatch }), favorites, providerModelPreferences: nextModelPreferences, ...(worktreeCleanupPatch === undefined diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 503521c087d72..47307e50b06fb 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -4,8 +4,10 @@ import { ProviderInstanceId, type ServerProvider, UsageLimitSourceId, + UsageSummary, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; import { type LimitAccount, @@ -13,6 +15,10 @@ import { sameUsageLimitCommandCoverage, withUsageLimitsCommands, collectLimitAccounts, + collectUsageAccounts, + usageAccountHistory, + usageAccountWindows, + prepaidCreditPercent, collectExternalUsageLinks, collectLimitNotices, collectLimitPools, @@ -23,6 +29,131 @@ import { } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); +const decodeUsageSummary = Schema.decodeSync(UsageSummary); + +it("attributes recorded profile usage only through exact unshared source bindings", () => { + const environmentId = EnvironmentId.make("env"); + const instanceId = ProviderInstanceId.make("api"); + const [account] = collectUsageAccounts( + new Map([ + [ + environmentId, + { + entry: { target: { label: "Local" } }, + serverConfig: { + providers: [ + provider({ + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + auth: { status: "authenticated", type: "apiKey" }, + }), + ], + }, + }, + ], + ]), + ); + const summary = decodeUsageSummary({ + contractVersion: 6, + readAt: "2026-09-03T12:00:00Z", + timeZone: "UTC", + sinceDay: "2026-09-01", + untilDay: "2026-09-03", + scanDurationMs: 0, + pricing: { status: "unavailable", source: "unavailable", fetchedAt: null, knownModels: 0 }, + sources: [ + { + fingerprint: { + hostId: "host", + provider: "claude", + resolvedHomePath: "/private/profile", + volumeId: "v", + }, + providerInstanceIds: [instanceId], + status: "ok", + scannedFiles: 1, + skippedFiles: 0, + malformedRecords: 0, + distinctSessions: 1, + message: null, + }, + ], + buckets: [ + { + day: "2026-09-03", + provider: "claude", + model: "claude", + sourcePath: "/private/profile", + totals: { + uncachedInputTokens: 10, + cachedInputTokens: 20, + cacheCreationTokens: 0, + outputTokens: 5, + reasoningTokens: 3, + }, + costUsd: 1.25, + cacheSavingsUsd: 0, + costSource: "modelPriced", + records: 1, + unpricedRecords: 0, + sessions: 1, + }, + ], + }); + const environments = [{ environmentId, summary, isPending: false }]; + expect(usageAccountHistory(account!, environments)).toMatchObject({ + state: "ready", + tokens: 35, + costUsd: 1.25, + }); + expect(JSON.stringify(usageAccountHistory(account!, environments))).not.toContain( + "/private/profile", + ); + const shared = { + ...summary, + sources: summary.sources.map((source) => ({ + ...source, + providerInstanceIds: [instanceId, ProviderInstanceId.make("other")], + })), + }; + expect( + usageAccountHistory(account!, [{ environmentId, summary: shared, isPending: false }]), + ).toEqual({ state: "shared" }); + expect( + usageAccountHistory(account!, [ + { environmentId: EnvironmentId.make("other"), summary, isPending: false }, + ]), + ).toEqual({ state: "unavailable" }); + expect( + usageAccountHistory(account!, [ + { + environmentId, + summary: { + ...summary, + sources: summary.sources.map((source) => ({ ...source, providerInstanceIds: [] })), + }, + isPending: false, + }, + ]), + ).toEqual({ state: "unassigned" }); + expect( + usageAccountHistory(account!, [ + { + environmentId, + isPending: false, + summary: { + ...summary, + sources: summary.sources.map((source) => ({ ...source, status: "failed" as const })), + }, + }, + ]), + ).toEqual({ state: "unavailable" }); + expect( + usageAccountHistory(account!, [ + { environmentId, isPending: false, summary: { ...summary, buckets: [] } }, + ]), + ).toMatchObject({ state: "ready", records: 0, tokens: 0 }); +}); const window = { id: "five_hour", @@ -1238,6 +1369,189 @@ describe("sameUsageLimitCommandCoverage", () => { }); }); +describe("usage account coverage", () => { + const api = provider({ + instanceId: ProviderInstanceId.make("anthropic-api"), + driver: ProviderDriverKind.make("claudeAgent"), + displayName: "Unlock Digital API", + auth: { status: "authenticated", type: "apiKey" }, + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [], + unavailable: { reason: "unsupported" }, + externalUsage: { + label: "Anthropic API billing", + url: "https://platform.claude.com/settings/billing", + }, + }, + }); + const presentations = (providers: readonly ServerProvider[]) => + new Map([ + [ + EnvironmentId.make("local"), + { + entry: { target: { label: "Laptop" } }, + serverConfig: { providers }, + }, + ], + ]); + + it("keeps an API account separate from an Enterprise spending window", () => { + const accounts = collectUsageAccounts( + presentations([ + api, + provider({ + instanceId: ProviderInstanceId.make("enterprise"), + driver: api.driver, + displayName: "UT EDU", + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [{ ...window, id: "monthly_spend", kind: "monthly", usedPercent: 100 }], + }, + }), + ]), + ); + expect(accounts.map(({ label, category }) => ({ label, category }))).toEqual([ + { label: "Unlock Digital API", category: "API key" }, + { label: "UT EDU", category: "Subscription" }, + ]); + expect(accounts[0]?.limits?.externalUsage?.label).toBe("Anthropic API billing"); + expect(accounts[0]?.limits?.windows).toEqual([]); + expect(accounts[1]?.limits?.windows[0]?.usedPercent).toBe(100); + }); + + it("automatically follows account additions, renames, and removals even with one billing URL", () => { + const second = { + ...api, + instanceId: ProviderInstanceId.make("tinfoil-api"), + driver: ProviderDriverKind.make("tinfoil"), + displayName: "Tinfoil", + }; + const initial = collectUsageAccounts(presentations([api])); + const added = collectUsageAccounts(presentations([api, second])); + expect(added).toHaveLength(2); + expect(added[0]?.key).toBe(initial[0]?.key); + expect(added[1]?.key).not.toBe(initial[0]?.key); + const renamed = collectUsageAccounts(presentations([{ ...api, displayName: "Renamed API" }])); + expect(renamed[0]?.key).toBe(initial[0]?.key); + expect(renamed[0]?.label).toBe("Renamed API"); + expect(collectUsageAccounts(presentations([second]))).toHaveLength(1); + expect(collectUsageAccounts(presentations([]))).toEqual([]); + }); + + it("keeps session availability aligned with an exhausted weekly allowance", () => { + const accounts = collectUsageAccounts( + presentations([ + provider({ + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [ + window, + { + id: "weekly", + kind: "weekly", + label: "Weekly", + usedPercent: 100, + resetsAt: "2026-09-06T12:00:00.000Z", + }, + ], + }, + }), + api, + ]), + ); + const windows = usageAccountWindows(accounts[0]!, now); + expect(windows[0]?.usedPercent).toBe(100); + expect(windows[0]?.resetsAt).toBe("2026-09-06T12:00:00.000Z"); + expect(usageAccountWindows(accounts[1]!, now)).toEqual([]); + }); + + it("covers new drivers, setup states and failed probes while excluding disabled connections", () => { + const accounts = collectUsageAccounts( + presentations([ + provider({ + instanceId: ProviderInstanceId.make("custom"), + driver: ProviderDriverKind.make("custom-driver"), + displayName: "Custom", + installed: false, + }), + provider({ + instanceId: ProviderInstanceId.make("failed"), + displayName: "Failed", + usageLimits: { + checkedAt: "2026-09-03T11:00:00.000Z", + windows: [], + unavailable: { reason: "probeFailed" }, + }, + }), + { ...api, auth: { status: "unknown" as const, type: "apiKey" } }, + provider({ enabled: false }), + ]), + ); + expect(accounts).toHaveLength(3); + expect(accounts[0]?.status).toBe("Setup required"); + expect(accounts[1]?.limits?.unavailable?.reason).toBe("probeFailed"); + expect(accounts[2]?.category).toBe("API key"); + }); + + it("keeps environment identity and projects only safe display metadata", () => { + const p = { + ...api, + auth: { ...api.auth, email: "private@example.com" }, + usageLimits: { ...api.usageLimits!, credentialFingerprint: "private-fingerprint" }, + }; + const input = presentations([p]); + input.set(EnvironmentId.make("remote"), { + entry: { target: { label: "Remote" } }, + serverConfig: { providers: [p] }, + }); + const all = collectUsageAccounts(input); + expect(all).toHaveLength(2); + expect(all[0]?.key).not.toBe(all[1]?.key); + const selected = collectUsageAccounts( + new Map([[EnvironmentId.make("remote"), input.get(EnvironmentId.make("remote"))!]]), + ); + expect(selected).toHaveLength(1); + expect(selected[0]?.environmentLabel).toBe("Remote"); + expect(JSON.stringify(all)).not.toContain("private@example.com"); + expect(JSON.stringify(all)).not.toContain("private-fingerprint"); + }); + + it("includes hub accounts with unavailable quotas without displaying credential filenames", () => { + const input = new Map([ + [ + EnvironmentId.make("local"), + { + entry: { target: { label: "Laptop" } }, + serverConfig: { + providers: [], + usageLimitSources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Team hub", + checkedAt: "2026-09-03T11:00:00.000Z", + accounts: [ + { + id: "private-credential.json", + driver: api.driver, + usageLimits: api.usageLimits!, + }, + ], + }, + ], + }, + }, + ], + ]); + const accounts = collectUsageAccounts(input); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.label).toBe("Team hub · Account 1"); + expect(accounts[0]?.limits?.externalUsage?.label).toBe("Anthropic API billing"); + expect(accounts[0]?.label).not.toContain("private-credential"); + }); +}); + describe("external usage settings", () => { it("deduplicates destinations across accounts and environments without inventing quota pools", () => { const managed = provider({ @@ -1290,3 +1604,14 @@ describe("ChatGPT sharing presentation", () => { ).toBe(false); }); }); + +describe("live prepaid credits", () => { + it("shows unknown funding separately from exhausted, funded, and overdrawn balances", () => { + expect(prepaidCreditPercent({ remainingUsd: 67.2, totalGrantedUsd: 96 })).toBeCloseTo(70, 4); + expect(prepaidCreditPercent({ remainingUsd: 0, totalGrantedUsd: 96 })).toBe(0); + expect(prepaidCreditPercent({ remainingUsd: -4.8, totalGrantedUsd: 96 })).toBe(0); + expect(prepaidCreditPercent({ remainingUsd: 96, totalGrantedUsd: 96 })).toBe(100); + expect(prepaidCreditPercent({ remainingUsd: 67.2 })).toBeNull(); + expect(prepaidCreditPercent(undefined)).toBeNull(); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 8ba0a1418a38d..e5cadd0faaabb 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -9,6 +9,8 @@ import { type ServerProviderUsageLimits, type ServerProviderUsageWindow, type UsageLimitSourceSnapshots, + type UsageSummary, + type ServerProviderPrepaidBalance, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -88,6 +90,189 @@ export type LimitPresentations = ReadonlyMap< } >; +export interface UsageAccount { + readonly key: string; + readonly driver: ServerProvider["driver"]; + readonly instanceId?: ProviderInstanceId; + readonly label: string; + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly category: "API key" | "Subscription" | "Connection" | "Usage hub"; + readonly status: "Connected" | "Checking" | "Setup required" | "Sign-in required" | "Unavailable"; + readonly plan: string | undefined; + readonly limits: Omit | undefined; + readonly resetCreditInput: ProviderConsumeResetCreditInput | undefined; +} + +function accountDisplayLimits(limits: ServerProviderUsageLimits | undefined) { + if (!limits) return undefined; + return { + checkedAt: limits.checkedAt, + windows: limits.windows, + unavailable: limits.unavailable, + externalUsage: limits.externalUsage, + resetCredits: limits.resetCredits, + creditBalance: limits.creditBalance, + prepaidBalance: limits.prepaidBalance, + }; +} + +export function prepaidCreditPercent(balance: ServerProviderPrepaidBalance | undefined) { + return balance?.totalGrantedUsd + ? Math.max(0, Math.min(100, (balance.remainingUsd / balance.totalGrantedUsd) * 100)) + : null; +} + +/** Connection coverage is independent of quota support and recorded activity. */ +export function collectUsageAccounts(presentations: LimitPresentations): readonly UsageAccount[] { + const accounts: UsageAccount[] = []; + for (const [environmentId, presentation] of presentations) { + const environmentLabel = presentation.entry.target.label; + for (const provider of presentation.serverConfig?.providers ?? []) { + if (!provider.enabled) continue; + const limits = accountDisplayLimits(provider.usageLimits); + const authType = provider.auth.type?.toLowerCase().replace(/[^a-z]/g, ""); + const category = + authType === "apikey" || authType === "api" + ? "API key" + : provider.auth.subscriptionSharing || limits?.windows.length + ? "Subscription" + : "Connection"; + const status = !provider.installed + ? "Setup required" + : provider.auth.status === "unauthenticated" + ? "Sign-in required" + : provider.status === "error" || !isProviderAvailable(provider) + ? "Unavailable" + : provider.auth.status === "authenticated" + ? "Connected" + : "Checking"; + accounts.push({ + key: JSON.stringify([environmentId, "provider", provider.instanceId]), + driver: provider.driver, + instanceId: provider.instanceId, + label: provider.displayName?.trim() || String(provider.driver), + environmentId, + environmentLabel, + category, + status, + plan: provider.auth.label, + limits, + resetCreditInput: { instanceId: provider.instanceId }, + }); + } + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + source.accounts.forEach((account, index) => { + accounts.push({ + key: JSON.stringify([environmentId, "hub", source.id, account.id, account.workspaceId]), + driver: account.driver, + label: `${source.label} · Account ${index + 1}`, + environmentId, + environmentLabel, + category: "Usage hub", + status: source.error ? "Unavailable" : "Connected", + plan: account.plan, + limits: accountDisplayLimits(account.usageLimits), + resetCreditInput: account.usageLimits.resetCredits?.nextCreditId + ? { + sourceId: source.id, + accountId: account.id, + creditId: account.usageLimits.resetCredits.nextCreditId, + } + : undefined, + }); + }); + } + } + return accounts; +} + +export function usageAccountWindows( + account: UsageAccount, + now: number, +): readonly ServerProviderUsageWindow[] { + const limits = account.limits; + if (!limits || limits.unavailable) return []; + return limits.windows + .map((window) => poolMember({ driver: account.driver, limits }, window, now).window) + .sort((left, right) => + account.driver === "antigravity" + ? Number(right.id.startsWith("gemini-")) - Number(left.id.startsWith("gemini-")) || + right.id.localeCompare(left.id, undefined, { numeric: true }) + : 0, + ); +} + +export interface UsageAccountHistoryEnvironment { + readonly environmentId: EnvironmentId; + readonly summary: UsageSummary | null; + readonly isPending: boolean; + readonly error?: string | null; +} + +export type UsageAccountHistory = + | { readonly state: "loading" | "unavailable" | "unassigned" | "shared" } + | { + readonly state: "ready"; + readonly tokens: number; + readonly costUsd: number; + readonly records: number; + readonly partial: boolean; + readonly unpriced: boolean; + readonly readAt: string; + readonly sinceDay: string; + readonly untilDay: string; + }; + +/** Configured profile provenance is explicit; it establishes local activity, never billed key spend. */ +export function usageAccountHistory( + account: UsageAccount, + environments: readonly UsageAccountHistoryEnvironment[], +): UsageAccountHistory { + const environment = environments.find((item) => item.environmentId === account.environmentId); + if (!environment || !account.instanceId) return { state: "unavailable" }; + const summary = environment.summary; + if (!summary) return { state: environment.isPending ? "loading" : "unavailable" }; + const sources = summary.sources.filter((source) => + source.providerInstanceIds?.includes(account.instanceId!), + ); + if (!sources.length) return { state: "unassigned" }; + if (sources.some((source) => source.providerInstanceIds!.length > 1)) return { state: "shared" }; + const readable = sources.filter( + (source) => source.status === "ok" || source.status === "partial", + ); + if (!readable.length) return { state: "unavailable" }; + const buckets = summary.buckets.filter((bucket) => + readable.some( + (source) => + source.fingerprint.provider === bucket.provider && + source.fingerprint.resolvedHomePath === bucket.sourcePath, + ), + ); + return { + state: "ready", + tokens: buckets.reduce( + (sum, bucket) => + sum + + bucket.totals.uncachedInputTokens + + bucket.totals.cachedInputTokens + + bucket.totals.cacheCreationTokens + + bucket.totals.outputTokens, + 0, + ), + costUsd: buckets.reduce((sum, bucket) => sum + bucket.costUsd, 0), + records: buckets.reduce((sum, bucket) => sum + bucket.records, 0), + partial: + Boolean(environment.error) || + readable.length !== sources.filter((source) => source.status !== "missing").length || + readable.some((source) => source.status === "partial" || source.refreshing), + unpriced: buckets.some((bucket) => bucket.unpricedRecords > 0), + readAt: summary.readAt, + sinceDay: summary.sinceDay, + untilDay: summary.untilDay, + }; +} + export function collectExternalUsageLinks(presentations: LimitPresentations) { const links = new Map< string, @@ -308,9 +493,12 @@ export function collectLimitNotices(presentations: LimitPresentations): readonly const notices: string[] = []; for (const presentation of presentations.values()) { const environmentLabel = presentation.entry.target.label; - for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { - if (provider.usageLimits?.unavailable?.reason === "unsupported") continue; - const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null; + for (const provider of presentation.serverConfig?.providers ?? []) { + if (!provider.enabled || !provider.installed || !isProviderAvailable(provider)) continue; + if (provider.usageLimits?.externalUsage && provider.auth.status === "authenticated") continue; + const notice = provider.usageLimits + ? limitsNotice(provider.usageLimits) + : "Subscription limits are unavailable for this provider."; const name = provider.displayName?.trim() || String(provider.driver); if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`); } @@ -428,7 +616,11 @@ function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWi return driver === "codex" ? window.kind : `${window.kind}:${window.id}`; } -function poolMember(account: LimitAccount, reported: ServerProviderUsageWindow, now: number) { +function poolMember>( + account: Account, + reported: ServerProviderUsageWindow, + now: number, +) { const spentWeekly = reported.kind === "session" ? account.limits.windows.find( @@ -535,7 +727,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L export function limitsNotice(limits: ServerProviderUsageLimits): string | null { if (limits.unavailable?.reason === "unsupported") { - return limits.unavailable.message ?? "This account has no subscription limits."; + return limits.unavailable.message ?? "Subscription limits are unavailable for this account."; } if (limits.unavailable?.reason === "probeFailed") { return limits.unavailable.message ?? "Could not read limits."; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff14f52dd0393..5c49810071e0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,6 +261,7 @@ overrides: expo-modules-core: 58.0.22 '@expo/fingerprint': 0.21.5 expo-network: 58.0.3 + node-pty: 1.2.0-beta.15 '@exodus/bytes>@noble/hashes': 1.8.0 '@react-navigation/core': 7.24.0 dbus-next>usocket: '-' @@ -894,7 +895,7 @@ importers: specifier: 'catalog:' version: 6.2.2 node-pty: - specifier: ^1.2.0-beta.15 + specifier: 1.2.0-beta.15 version: 1.2.0-beta.15(patch_hash=f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651) playwright-core: specifier: 1.60.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c33955e1b81fa..b9ec7827cc65d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -230,6 +230,7 @@ overrides: # Fresh lockfile resolution must keep these packages on the revisions their patches target. "@expo/fingerprint": 0.21.5 expo-network: 58.0.3 + node-pty: 1.2.0-beta.15 # Keep jsdom's optional peer consistent so Vite+ has one test-runner instance. "@exodus/bytes>@noble/hashes": "1.8.0" # Earlier React Navigation declarations create a circular type alias in TypeScript 7.