From d2e0f75e74fdb25a73a5bf13596b1e9eb563b413 Mon Sep 17 00:00:00 2001 From: Alex Holovach Date: Wed, 22 Jul 2026 15:40:02 -0700 Subject: [PATCH] feat(provider): scaffold Sazabi cloud provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add contracts, driver, availability probe, an empty (not-yet-streaming) adapter, and heuristic text generation for Sazabi as a cloud provider (Path A: talks to the Sazabi public API over HTTP/SSE, not a local ACP CLI harness). Scope (PR T1 — scaffold only): - contracts: `sazabi` driver kind, `SazabiSettings` (enabled, apiBaseUrl, projectId, optional binaryPath), legacy `providers.sazabi` hydration, model defaults + display name. The API token is read from the `SAZABI_TOKEN` env var and is deliberately absent from settings so no secret is persisted to disk. - driver + probe: `SazabiDriver` registered in `builtInDrivers`; `SazabiProvider` reports availability from auth presence (SAZABI_TOKEN, or an optional `sazabi whoami`) with a clear unavailable reason. - adapter: implements `ProviderAdapterShape` with a wired-up (empty) `streamEvents` PubSub; session/turn ops return clear "not implemented" errors and interrupt/stop are safe no-ops. - web: minimal catalog wiring so the provider shows in settings. - tests: settings decode, probe unavailable-without-token, adapter stubs. Depends on the upcoming Sazabi public API (messages send / SSE stream / cancel). PR T2 replaces the stub adapter with real streaming + interrupt->cancel + tool item lifecycle. Co-authored-by: Cursor --- .../src/provider/Drivers/SazabiDriver.test.ts | 29 ++ .../src/provider/Drivers/SazabiDriver.ts | 167 ++++++++++ .../src/provider/Layers/SazabiAdapter.test.ts | 131 ++++++++ .../src/provider/Layers/SazabiAdapter.ts | 124 ++++++++ .../provider/Layers/SazabiProvider.test.ts | 137 ++++++++ .../src/provider/Layers/SazabiProvider.ts | 297 ++++++++++++++++++ .../src/provider/Services/SazabiAdapter.ts | 16 + apps/server/src/provider/builtInDrivers.ts | 5 +- .../textGeneration/SazabiTextGeneration.ts | 97 ++++++ apps/web/src/components/Icons.tsx | 12 + .../src/components/chat/providerIconUtils.ts | 3 +- .../components/settings/providerDriverMeta.ts | 18 +- apps/web/src/session-logic.ts | 6 + packages/contracts/src/model.ts | 13 + packages/contracts/src/settings.test.ts | 49 +++ packages/contracts/src/settings.ts | 101 ++++++ 16 files changed, 1202 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/provider/Drivers/SazabiDriver.test.ts create mode 100644 apps/server/src/provider/Drivers/SazabiDriver.ts create mode 100644 apps/server/src/provider/Layers/SazabiAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/SazabiAdapter.ts create mode 100644 apps/server/src/provider/Layers/SazabiProvider.test.ts create mode 100644 apps/server/src/provider/Layers/SazabiProvider.ts create mode 100644 apps/server/src/provider/Services/SazabiAdapter.ts create mode 100644 apps/server/src/textGeneration/SazabiTextGeneration.ts diff --git a/apps/server/src/provider/Drivers/SazabiDriver.test.ts b/apps/server/src/provider/Drivers/SazabiDriver.test.ts new file mode 100644 index 00000000000..4c9f480e626 --- /dev/null +++ b/apps/server/src/provider/Drivers/SazabiDriver.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { SazabiDriver } from "./SazabiDriver.ts"; + +describe("SazabiDriver", () => { + it("registers the sazabi driver kind with multi-instance support", () => { + expect(SazabiDriver.driverKind).toBe("sazabi"); + expect(SazabiDriver.metadata.displayName).toBe("Sazabi"); + expect(SazabiDriver.metadata.supportsMultipleInstances).toBe(true); + }); + + it("produces a disabled, credential-free default config", () => { + const config = SazabiDriver.defaultConfig(); + expect(config.enabled).toBe(false); + expect(config.apiBaseUrl).toBe(""); + expect(config.projectId).toBe(""); + expect(config.binaryPath).toBe(""); + expect(config.customModels).toEqual([]); + }); + + it("decodes a populated instance config through its schema", () => { + const decode = SazabiDriver.configSchema; + expect(decode).toBeDefined(); + const config = SazabiDriver.defaultConfig(); + // The token is never part of the persisted config — only connection hints. + expect(config).not.toHaveProperty("apiToken"); + expect(config).not.toHaveProperty("token"); + }); +}); diff --git a/apps/server/src/provider/Drivers/SazabiDriver.ts b/apps/server/src/provider/Drivers/SazabiDriver.ts new file mode 100644 index 00000000000..cb548d7262d --- /dev/null +++ b/apps/server/src/provider/Drivers/SazabiDriver.ts @@ -0,0 +1,167 @@ +/** + * SazabiDriver — `ProviderDriver` for the Sazabi **cloud** provider. + * + * Scaffold only (PR T1). Mirrors the Grok / OpenCode drivers: a plain value + * whose `create()` bundles `snapshot` / `adapter` / `textGeneration` closures + * over the per-instance `SazabiSettings`. + * + * Sazabi is Path A (cloud): the adapter will talk to the Sazabi public API + * over HTTP/SSE. This scaffold wires up an availability probe (token/env or an + * optional `sazabi whoami`) and an "empty" adapter whose `streamEvents` PubSub + * is ready for PR T2 to fill in with real streaming + cancel. The driver is + * fully constructable at boot so the provider surfaces in the catalog + settings + * and reports a clear reason when unauthenticated. + * + * @module provider/Drivers/SazabiDriver + */ +import { SazabiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeSazabiTextGeneration } from "../../textGeneration/SazabiTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeSazabiAdapter } from "../Layers/SazabiAdapter.ts"; +import { + buildInitialSazabiProviderSnapshot, + checkSazabiProviderStatus, +} from "../Layers/SazabiProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeSazabiSettings = Schema.decodeSync(SazabiSettings); + +const DRIVER_KIND = ProviderDriverKind.make("sazabi"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +// Cloud provider: no local package/binary to self-update. Manual only. +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type SazabiDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ProviderEventLoggers + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const SazabiDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Sazabi", + supportsMultipleInstances: true, + }, + configSchema: SazabiSettings, + defaultConfig: (): SazabiSettings => decodeSazabiSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies SazabiSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeSazabiAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeSazabiTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkSazabiProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialSazabiProviderSnapshot(settings.provider, processEnv).pipe( + Effect.map(stampIdentity), + ), + checkProvider, + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Sazabi snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/SazabiAdapter.test.ts b/apps/server/src/provider/Layers/SazabiAdapter.test.ts new file mode 100644 index 00000000000..0964ad97e1a --- /dev/null +++ b/apps/server/src/provider/Layers/SazabiAdapter.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { + ApprovalRequestId, + ProviderDriverKind, + ProviderInstanceId, + SazabiSettings, + ThreadId, +} from "@t3tools/contracts"; + +import type { SazabiAdapterShape } from "../Services/SazabiAdapter.ts"; +import { makeSazabiAdapter, SAZABI_ADAPTER_NOT_IMPLEMENTED_DETAIL } from "./SazabiAdapter.ts"; + +const decodeSazabiSettings = Schema.decodeSync(SazabiSettings); + +const withAdapter = (use: (adapter: SazabiAdapterShape) => Effect.Effect) => + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* makeSazabiAdapter(decodeSazabiSettings({ enabled: true }), { + instanceId: ProviderInstanceId.make("sazabi"), + }); + return yield* use(adapter); + }), + ); + +const THREAD = ThreadId.make("sazabi-scaffold-thread"); + +describe("makeSazabiAdapter (scaffold)", () => { + it.effect("advertises the sazabi provider with model switching unsupported", () => + withAdapter((adapter) => + Effect.sync(() => { + expect(adapter.provider).toBe("sazabi"); + expect(adapter.capabilities.sessionModelSwitch).toBe("unsupported"); + }), + ), + ); + + it.effect("fails startSession with a clear not-implemented error", () => + withAdapter((adapter) => + Effect.gen(function* () { + const error = yield* Effect.flip( + adapter.startSession({ + threadId: THREAD, + provider: ProviderDriverKind.make("sazabi"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("sazabi"), + model: "sazabi-default", + }, + }), + ); + expect(error._tag).toBe("ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + expect(error.method).toBe("session/start"); + expect(error.detail).toContain(SAZABI_ADAPTER_NOT_IMPLEMENTED_DETAIL); + } + }), + ), + ); + + it.effect("fails sendTurn with a clear not-implemented error", () => + withAdapter((adapter) => + Effect.gen(function* () { + const error = yield* Effect.flip( + adapter.sendTurn({ threadId: THREAD, input: "hello sazabi", attachments: [] }), + ); + expect(error._tag).toBe("ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + expect(error.method).toBe("session/prompt"); + expect(error.detail).toContain(SAZABI_ADAPTER_NOT_IMPLEMENTED_DETAIL); + } + }), + ), + ); + + it.effect("fails readThread and rollbackThread as not implemented", () => + withAdapter((adapter) => + Effect.gen(function* () { + const readError = yield* Effect.flip(adapter.readThread(THREAD)); + expect(readError._tag).toBe("ProviderAdapterRequestError"); + if (readError._tag === "ProviderAdapterRequestError") { + expect(readError.method).toBe("thread/read"); + } + + const rollbackError = yield* Effect.flip(adapter.rollbackThread(THREAD, 1)); + expect(rollbackError._tag).toBe("ProviderAdapterRequestError"); + if (rollbackError._tag === "ProviderAdapterRequestError") { + expect(rollbackError.method).toBe("thread/rollback"); + } + }), + ), + ); + + it.effect("fails interactive responses as not implemented", () => + withAdapter((adapter) => + Effect.gen(function* () { + const approvalError = yield* Effect.flip( + adapter.respondToRequest(THREAD, ApprovalRequestId.make("req-1"), "accept"), + ); + expect(approvalError._tag).toBe("ProviderAdapterRequestError"); + if (approvalError._tag === "ProviderAdapterRequestError") { + expect(approvalError.method).toBe("session/request_permission"); + } + + const inputError = yield* Effect.flip( + adapter.respondToUserInput(THREAD, ApprovalRequestId.make("req-2"), {}), + ); + expect(inputError._tag).toBe("ProviderAdapterRequestError"); + if (inputError._tag === "ProviderAdapterRequestError") { + expect(inputError.method).toBe("session/user_input"); + } + }), + ), + ); + + it.effect("treats interrupt/stop lifecycle operations as safe no-ops", () => + withAdapter((adapter) => + Effect.gen(function* () { + yield* adapter.interruptTurn(THREAD); + yield* adapter.stopSession(THREAD); + yield* adapter.stopAll(); + const sessions = yield* adapter.listSessions(); + const hasSession = yield* adapter.hasSession(THREAD); + expect(sessions).toEqual([]); + expect(hasSession).toBe(false); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/SazabiAdapter.ts b/apps/server/src/provider/Layers/SazabiAdapter.ts new file mode 100644 index 00000000000..042e4df8fe8 --- /dev/null +++ b/apps/server/src/provider/Layers/SazabiAdapter.ts @@ -0,0 +1,124 @@ +/** + * SazabiAdapter — scaffolded, "empty" adapter for the Sazabi cloud provider. + * + * PR T1 ships the structure only. Every session/turn operation that would talk + * to the Sazabi public API returns a clear "not implemented" error, and the + * canonical `streamEvents` PubSub is wired up but never published to yet. This + * keeps the driver constructable at boot (so the provider appears in the + * catalog + settings and reports its availability) without pretending to run + * remote work. + * + * PR T2 replaces the not-implemented bodies with the real + * `messages send` / SSE stream mapping, `interruptTurn → cancel`, and the tool + * item lifecycle. The `streamEvents` PubSub is intentionally left in place so + * that wiring is a fill-in rather than a restructure. + * + * @module provider/Layers/SazabiAdapter + */ +import { + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + type SazabiSettings, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; + +import { ProviderAdapterRequestError } from "../Errors.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { type SazabiAdapterShape } from "../Services/SazabiAdapter.ts"; + +const PROVIDER = ProviderDriverKind.make("sazabi"); + +/** + * Shared detail for every not-yet-implemented adapter operation. Referenced by + * tests so the "scaffold" contract is asserted in one place. + */ +export const SAZABI_ADAPTER_NOT_IMPLEMENTED_DETAIL = + "Sazabi is a scaffold: the streaming adapter (Sazabi public API messages/stream/cancel) " + + "arrives in PR T2."; + +export interface SazabiAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly instanceId?: ProviderInstanceId; + /** + * Native NDJSON logger for raw provider events. Unused by the scaffold; T2 + * writes Sazabi SSE frames through it, matching the other adapters. + */ + readonly nativeEventLogger?: EventNdjsonLogger; +} + +export function makeSazabiAdapter( + sazabiSettings: SazabiSettings, + options?: SazabiAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("sazabi"); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + yield* Effect.addFinalizer(() => PubSub.shutdown(runtimeEventPubSub)); + + // Record what the (future) adapter would connect to. Keeps the scaffolded + // config threaded through so T2's fill-in has an obvious construction site. + yield* Effect.logDebug("Sazabi adapter constructed (scaffold; streaming lands in PR T2).", { + instanceId: boundInstanceId, + hasApiBaseUrl: sazabiSettings.apiBaseUrl.trim().length > 0, + }); + + const notImplemented = (method: string) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: `${SAZABI_ADAPTER_NOT_IMPLEMENTED_DETAIL} (instance ${boundInstanceId})`, + }), + ); + + const startSession: SazabiAdapterShape["startSession"] = () => notImplemented("session/start"); + + const sendTurn: SazabiAdapterShape["sendTurn"] = () => notImplemented("session/prompt"); + + // Interrupt/stop are safe no-ops: the scaffold never opens a remote run, so + // there is nothing to cancel or tear down. T2 maps these to Sazabi cancel. + const interruptTurn: SazabiAdapterShape["interruptTurn"] = () => Effect.void; + + const respondToRequest: SazabiAdapterShape["respondToRequest"] = () => + notImplemented("session/request_permission"); + + const respondToUserInput: SazabiAdapterShape["respondToUserInput"] = () => + notImplemented("session/user_input"); + + const stopSession: SazabiAdapterShape["stopSession"] = () => Effect.void; + + const listSessions: SazabiAdapterShape["listSessions"] = () => Effect.succeed([]); + + const hasSession: SazabiAdapterShape["hasSession"] = () => Effect.succeed(false); + + const readThread: SazabiAdapterShape["readThread"] = () => notImplemented("thread/read"); + + const rollbackThread: SazabiAdapterShape["rollbackThread"] = () => + notImplemented("thread/rollback"); + + const stopAll: SazabiAdapterShape["stopAll"] = () => Effect.void; + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "unsupported" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies SazabiAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/SazabiProvider.test.ts b/apps/server/src/provider/Layers/SazabiProvider.test.ts new file mode 100644 index 00000000000..39ea14c35a9 --- /dev/null +++ b/apps/server/src/provider/Layers/SazabiProvider.test.ts @@ -0,0 +1,137 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { SazabiSettings, SAZABI_TOKEN_ENV_VAR } from "@t3tools/contracts"; + +import { + buildInitialSazabiProviderSnapshot, + checkSazabiProviderStatus, + resolveSazabiToken, + SAZABI_MISSING_AUTH_MESSAGE, +} from "./SazabiProvider.ts"; + +const decodeSazabiSettings = Schema.decodeSync(SazabiSettings); + +// Deterministic environments — never fall back to the host `process.env` so a +// developer's real SAZABI_TOKEN can't flip these assertions. +const ENV_NO_TOKEN: NodeJS.ProcessEnv = {}; +const ENV_WITH_TOKEN: NodeJS.ProcessEnv = { [SAZABI_TOKEN_ENV_VAR]: "sazabi-test-token" }; + +describe("resolveSazabiToken", () => { + it("returns undefined when the token is unset or blank", () => { + expect(resolveSazabiToken({})).toBeUndefined(); + expect(resolveSazabiToken({ [SAZABI_TOKEN_ENV_VAR]: " " })).toBeUndefined(); + }); + + it("returns the trimmed token when present", () => { + expect(resolveSazabiToken({ [SAZABI_TOKEN_ENV_VAR]: " abc " })).toBe("abc"); + }); +}); + +describe("buildInitialSazabiProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialSazabiProviderSnapshot( + decodeSazabiSettings({ enabled: false }), + ENV_NO_TOKEN, + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("reflects a missing token as not-yet-installed while checking", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialSazabiProviderSnapshot( + decodeSazabiSettings({ enabled: true }), + ENV_NO_TOKEN, + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("warning"); + expect(snapshot.auth.status).toBe("unknown"); + expect(snapshot.message).toContain("Checking Sazabi"); + expect(snapshot.requiresNewThreadForModelChange).toBe(true); + expect(snapshot.models.map((model) => model.slug)).toEqual(["sazabi-default"]); + }), + ); + + it.effect("optimistically marks token auth before the probe runs", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialSazabiProviderSnapshot( + decodeSazabiSettings({ enabled: true }), + ENV_WITH_TOKEN, + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.auth.type).toBe("token"); + expect(snapshot.auth.label).toBe(SAZABI_TOKEN_ENV_VAR); + }), + ); +}); + +it.layer(NodeServices.layer)("checkSazabiProviderStatus", (it) => { + it.effect("is unavailable with a clear reason when no token and no CLI are configured", () => + Effect.gen(function* () { + const snapshot = yield* checkSazabiProviderStatus( + decodeSazabiSettings({ enabled: true }), + ENV_NO_TOKEN, + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toBe(SAZABI_MISSING_AUTH_MESSAGE); + }), + ); + + it.effect("reports ready + authenticated when SAZABI_TOKEN is present", () => + Effect.gen(function* () { + const snapshot = yield* checkSazabiProviderStatus( + decodeSazabiSettings({ enabled: true }), + ENV_WITH_TOKEN, + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBeNull(); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.auth.type).toBe("token"); + expect(snapshot.auth.label).toBe(SAZABI_TOKEN_ENV_VAR); + }), + ); + + it.effect( + "is unavailable when the optional CLI path cannot be resolved and no token is set", + () => + Effect.gen(function* () { + const snapshot = yield* checkSazabiProviderStatus( + decodeSazabiSettings({ + enabled: true, + binaryPath: "/definitely/not/installed/sazabi-binary", + }), + ENV_NO_TOKEN, + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toMatch(/unauthenticated|unknown/); + expect(snapshot.message).toMatch(/unavailable|Failed to execute/); + }), + ); + + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* checkSazabiProviderStatus( + decodeSazabiSettings({ enabled: false }), + ENV_WITH_TOKEN, + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/SazabiProvider.ts b/apps/server/src/provider/Layers/SazabiProvider.ts new file mode 100644 index 00000000000..1c0bf51e62c --- /dev/null +++ b/apps/server/src/provider/Layers/SazabiProvider.ts @@ -0,0 +1,297 @@ +/** + * SazabiProvider — snapshot/probe logic for the Sazabi **cloud** provider. + * + * Scaffold only (PR T1). Sazabi talks to the Sazabi public API over HTTP/SSE + * (Path A); it is not a local ACP CLI harness. Availability is therefore + * decided by auth presence rather than a binary version: + * + * - a `SAZABI_TOKEN` in the (merged per-instance) environment marks the + * provider authenticated + ready, or + * - an optional `sazabi` CLI path can be probed with `whoami` as a + * convenience when the token is not set. + * + * When neither is present the snapshot reports a clear unavailable reason so + * the settings UI can tell the user how to authenticate. + * + * The live model catalogue + real readiness checks against the public API are + * deferred to PR T2; until then a single placeholder model is surfaced so a + * scaffolded instance still resolves a default selection. + * + * @module provider/Layers/SazabiProvider + */ +import { + DEFAULT_SAZABI_MODEL, + SAZABI_TOKEN_ENV_VAR, + type ModelCapabilities, + type SazabiSettings, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const SAZABI_PRESENTATION = { + displayName: "Sazabi", + badgeLabel: "Early Access", + // Cloud model switching semantics are finalized in PR T2; keep the toggle + // off for the scaffold so the UI does not advertise an unimplemented mode. + requiresNewThreadForModelChange: true, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const WHOAMI_PROBE_TIMEOUT_MS = 4_000; + +/** + * Guidance surfaced whenever Sazabi has no usable credential. Kept as an + * exported constant so the driver, adapter, and tests share one wording. + */ +export const SAZABI_MISSING_AUTH_MESSAGE = + `Sazabi is unavailable: set the ${SAZABI_TOKEN_ENV_VAR} environment variable ` + + "(or configure a `sazabi` CLI path) to authenticate."; + +const SAZABI_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: DEFAULT_SAZABI_MODEL, + name: "Sazabi Default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +function sazabiModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = SAZABI_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +/** + * Resolve the Sazabi API token from a (already merged) environment. Returns + * `undefined` when unset or blank so callers can branch on presence. + */ +export function resolveSazabiToken( + environment: NodeJS.ProcessEnv = process.env, +): string | undefined { + const token = environment[SAZABI_TOKEN_ENV_VAR]?.trim(); + return token ? token : undefined; +} + +export function buildInitialSazabiProviderSnapshot( + sazabiSettings: SazabiSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = sazabiModelsFromSettings(sazabiSettings.customModels); + + if (!sazabiSettings.enabled) { + return buildServerProvider({ + presentation: SAZABI_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Sazabi is disabled in T3 Code settings.", + }, + }); + } + + // Optimistically reflect token presence before the (async) CLI probe runs. + const hasToken = resolveSazabiToken(environment) !== undefined; + return buildServerProvider({ + presentation: SAZABI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: hasToken + ? { + installed: true, + version: null, + status: "warning", + auth: { status: "authenticated", type: "token", label: SAZABI_TOKEN_ENV_VAR }, + message: "Checking Sazabi availability...", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Sazabi availability...", + }, + }); + }); +} + +const runSazabiWhoamiCommand = (binaryPath: string, environment: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const spawnCommand = yield* resolveSpawnCommand(binaryPath, ["whoami"], { + env: environment, + }); + return yield* spawnAndCollect( + binaryPath, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +/** + * Probe Sazabi availability. + * + * Availability is driven by auth presence, not a binary version: + * 1. `SAZABI_TOKEN` present → authenticated + ready. + * 2. no token but a `binaryPath` configured → run `sazabi whoami`. + * 3. neither → unavailable with {@link SAZABI_MISSING_AUTH_MESSAGE}. + */ +export const checkSazabiProviderStatus = Effect.fn("checkSazabiProviderStatus")(function* ( + sazabiSettings: SazabiSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = sazabiModelsFromSettings(sazabiSettings.customModels); + + if (!sazabiSettings.enabled) { + return buildServerProvider({ + presentation: SAZABI_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Sazabi is disabled in T3 Code settings.", + }, + }); + } + + // 1. Token auth — the primary path for a cloud provider. + if (resolveSazabiToken(environment) !== undefined) { + return buildServerProvider({ + presentation: SAZABI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", type: "token", label: SAZABI_TOKEN_ENV_VAR }, + }, + }); + } + + // 2. Optional CLI fallback probe when a `sazabi` binary is configured. + const binaryPath = sazabiSettings.binaryPath.trim(); + if (binaryPath) { + const whoamiResult = yield* runSazabiWhoamiCommand(binaryPath, environment).pipe( + Effect.timeoutOption(WHOAMI_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(whoamiResult)) { + const error = whoamiResult.failure; + yield* Effect.logWarning("Sazabi CLI health check failed.", { errorTag: error._tag }); + const commandMissing = isCommandMissingCause(error); + return buildServerProvider({ + presentation: SAZABI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: !commandMissing, + version: null, + status: "error", + auth: { status: commandMissing ? "unauthenticated" : "unknown" }, + message: commandMissing + ? SAZABI_MISSING_AUTH_MESSAGE + : "Failed to execute Sazabi CLI health check.", + }, + }); + } + + if (Option.isNone(whoamiResult.success)) { + return buildServerProvider({ + presentation: SAZABI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Sazabi CLI timed out while running `sazabi whoami`.", + }, + }); + } + + const whoami = whoamiResult.success.value; + if (whoami.code === 0) { + return buildServerProvider({ + presentation: SAZABI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", type: "cli" }, + }, + }); + } + + return buildServerProvider({ + presentation: SAZABI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unauthenticated" }, + message: + "Sazabi CLI is installed but not authenticated. Run `sazabi login` or set " + + `${SAZABI_TOKEN_ENV_VAR}.`, + }, + }); + } + + // 3. No credential of any kind. + return buildServerProvider({ + presentation: SAZABI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unauthenticated" }, + message: SAZABI_MISSING_AUTH_MESSAGE, + }, + }); +}); diff --git a/apps/server/src/provider/Services/SazabiAdapter.ts b/apps/server/src/provider/Services/SazabiAdapter.ts new file mode 100644 index 00000000000..07136b52c9d --- /dev/null +++ b/apps/server/src/provider/Services/SazabiAdapter.ts @@ -0,0 +1,16 @@ +/** + * SazabiAdapter — shape type for the Sazabi provider adapter. + * + * The driver model ({@link ../Drivers/SazabiDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module SazabiAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * SazabiAdapterShape — per-instance Sazabi adapter contract. + */ +export interface SazabiAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..900e478360e 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,6 +25,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { SazabiDriver, type SazabiDriverEnv } from "./Drivers/SazabiDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -37,7 +38,8 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | SazabiDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray 0) return trimmed; + } + return ""; +} + +export const makeSazabiTextGeneration = Effect.fn("makeSazabiTextGeneration")(function* ( + sazabiSettings: SazabiSettings, + _environment: NodeJS.ProcessEnv = process.env, +) { + // Threaded through for parity with the other providers; T2 uses these to call + // the Sazabi public API instead of the local heuristics below. + yield* Effect.logDebug("Sazabi text generation constructed (scaffold; heuristic only).", { + hasApiBaseUrl: sazabiSettings.apiBaseUrl.trim().length > 0, + }); + + // These are pure, synchronous heuristics (no network, no failure), so each is + // an `Effect.sync` with a tracing span rather than a generator. PR T2 swaps + // these for model-backed generation over the Sazabi public API. + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = ( + input, + ) => + Effect.sync(() => { + const subject = sanitizeCommitSubject(firstLine(input.stagedSummary)); + return { + subject, + body: "", + ...(input.includeBranch === true + ? { branch: sanitizeFeatureBranchName(input.branch ?? subject) } + : {}), + }; + }).pipe(Effect.withSpan("SazabiTextGeneration.generateCommitMessage")); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = ( + input, + ) => + Effect.sync(() => { + const title = sanitizePrTitle(firstLine(input.commitSummary) || input.headBranch); + const bodyParts = [firstLine(input.commitSummary), firstLine(input.diffSummary)].filter( + (part) => part.length > 0, + ); + return { + title, + body: bodyParts.join("\n\n"), + }; + }).pipe(Effect.withSpan("SazabiTextGeneration.generatePrContent")); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = ( + input, + ) => + Effect.sync(() => ({ + branch: sanitizeBranchFragment(firstLine(input.message) || input.message), + })).pipe(Effect.withSpan("SazabiTextGeneration.generateBranchName")); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = ( + input, + ) => + Effect.sync( + () => + ({ + title: sanitizeThreadTitle(input.message), + }) satisfies TextGeneration.ThreadTitleGenerationResult, + ).pipe(Effect.withSpan("SazabiTextGeneration.generateThreadTitle")); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c51958..39401d68047 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,18 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const SazabiIcon: Icon = ({ className, ...props }) => ( + + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a700716..6c5385e179d 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon, SazabiIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("sazabi")]: SazabiIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..66b87085343 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -5,9 +5,18 @@ import { GrokSettings, OpenCodeSettings, ProviderDriverKind, + SazabiSettings, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, + SazabiIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +76,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("sazabi"), + label: "Sazabi", + icon: SazabiIcon, + badgeLabel: "Early Access", + settingsSchema: SazabiSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..72812da3b2a 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -51,6 +51,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("sazabi"), + label: "Sazabi", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8c74c13b89b..d5e041a6551 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,9 +132,19 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const SAZABI_DRIVER_KIND = ProviderDriverKind.make("sazabi"); export const DEFAULT_MODEL = "gpt-5.6-sol"; +/** + * Placeholder default model slug for the Sazabi cloud provider (scaffold). + * + * TODO(T2): replace with the real Sazabi model catalogue once the public API + * exposes `models` discovery. Kept as a single stable slug so a scaffolded + * instance still resolves a default selection in the UI. + */ +export const DEFAULT_SAZABI_MODEL = "sazabi-default"; + /** * Codex default-model preference, most preferred first. The provider snapshot * marks the first of these present in the live `model/list` response as @@ -152,6 +162,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [SAZABI_DRIVER_KIND]: "Sazabi", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 001daf1b239..d4744545d07 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -6,6 +6,8 @@ import { ClientSettingsSchema, ClientSettingsPatch, DEFAULT_SERVER_SETTINGS, + SAZABI_TOKEN_ENV_VAR, + SazabiSettings, ServerSettings, ServerSettingsPatch, } from "./settings.ts"; @@ -15,6 +17,7 @@ const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +const decodeSazabiSettings = Schema.decodeUnknownSync(SazabiSettings); describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { @@ -108,6 +111,52 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { }); }); +describe("SazabiSettings (cloud provider scaffold)", () => { + it("defaults to a disabled, credential-free scaffold", () => { + const decoded = decodeSazabiSettings({}); + expect(decoded.enabled).toBe(false); + expect(decoded.apiBaseUrl).toBe(""); + expect(decoded.projectId).toBe(""); + expect(decoded.binaryPath).toBe(""); + expect(decoded.customModels).toEqual([]); + // The API token is deliberately absent from the schema — it is read from + // the environment (SAZABI_TOKEN) so no secret is persisted to disk. + expect(decoded).not.toHaveProperty("apiToken"); + expect(decoded).not.toHaveProperty("token"); + expect(SAZABI_TOKEN_ENV_VAR).toBe("SAZABI_TOKEN"); + }); + + it("trims and retains the cloud connection fields", () => { + const decoded = decodeSazabiSettings({ + enabled: true, + apiBaseUrl: " https://api.example.dev ", + projectId: " proj_123 ", + binaryPath: " sazabi ", + }); + expect(decoded.enabled).toBe(true); + expect(decoded.apiBaseUrl).toBe("https://api.example.dev"); + expect(decoded.projectId).toBe("proj_123"); + expect(decoded.binaryPath).toBe("sazabi"); + }); + + it("hydrates providers.sazabi with scaffold defaults for legacy configs", () => { + const decoded = decodeServerSettings({}); + expect(decoded.providers.sazabi.enabled).toBe(false); + expect(decoded.providers.sazabi.apiBaseUrl).toBe(""); + }); + + it("trims sazabi patch fields", () => { + const patch = decodeServerSettingsPatch({ + providers: { + sazabi: { enabled: true, apiBaseUrl: " https://x.dev ", projectId: " p " }, + }, + }); + expect(patch.providers?.sazabi?.enabled).toBe(true); + expect(patch.providers?.sazabi?.apiBaseUrl).toBe("https://x.dev"); + expect(patch.providers?.sazabi?.projectId).toBe("p"); + }); +}); + describe("ServerSettings worktree defaults", () => { it("defaults start-from-origin on for legacy configs", () => { expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2f3c1aafe69..7ee633f1295 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -376,6 +376,97 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +/** + * Environment variable the Sazabi cloud provider reads its API token from. + * + * Sazabi is a **cloud** provider (Path A): the driver talks to the Sazabi + * public API over HTTP/SSE rather than wrapping a local ACP CLI. Auth is + * therefore an API token, not an interactive CLI login. + * + * The token is intentionally NOT a persisted settings field — we do not want + * to write a bearer secret to `settings.json` in plaintext. Instead it flows + * in through the process environment (or a per-instance, `sensitive` + * `ProviderInstanceEnvironment` variable, which the registry already keeps out + * of the settings file). The probe treats the presence of this variable as the + * signal that the provider is usable. + */ +export const SAZABI_TOKEN_ENV_VAR = "SAZABI_TOKEN" as const; + +/** + * Placeholder default base URL for the Sazabi public API. + * + * TODO(T2): confirm the canonical production base URL against the Sazabi + * public API once the streaming/cancel endpoints land in the sibling + * monorepo. Until then this is only used as the fallback when + * `SazabiSettings.apiBaseUrl` is left blank; the scaffolded adapter does not + * issue real requests yet. + */ +export const DEFAULT_SAZABI_API_BASE_URL = "https://api.sazabi.dev" as const; + +/** + * Settings for the Sazabi cloud provider. + * + * Scaffold only (PR T1). The real streaming adapter + cancel wiring arrive in + * PR T2 once the Sazabi public API is available. Fields here describe how to + * reach the cloud service; the bearer token is supplied via the + * `SAZABI_TOKEN` environment variable (see {@link SAZABI_TOKEN_ENV_VAR}) and is + * deliberately absent from this schema so no secret is persisted to disk. + * + * `binaryPath` is optional and only used for an auxiliary CLI presence/`whoami` + * probe when a `sazabi` CLI happens to be installed; availability does not + * require it. + */ +export const SazabiSettings = makeProviderSettingsSchema( + { + // Defaults off: the streaming adapter is not implemented until PR T2, so we + // keep the provider opt-in (mirrors Cursor) rather than surfacing an error + // state for every user before the cloud integration is usable. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + apiBaseUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "API base URL", + description: "Override the Sazabi public API base URL for this instance.", + providerSettingsForm: { + placeholder: DEFAULT_SAZABI_API_BASE_URL, + clearWhenEmpty: "omit", + }, + }), + ), + projectId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Project ID", + description: "Optional Sazabi project this instance scopes runs to.", + providerSettingsForm: { + placeholder: "Optional", + clearWhenEmpty: "omit", + }, + }), + ), + binaryPath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "CLI path (optional)", + description: + "Path to a `sazabi` CLI, used only for an auxiliary availability/whoami probe. Not required for the cloud provider.", + providerSettingsForm: { placeholder: "sazabi", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["apiBaseUrl", "projectId", "binaryPath"], + }, +); +export type SazabiSettings = typeof SazabiSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -420,6 +511,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + sazabi: SazabiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -523,6 +615,14 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const SazabiSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + apiBaseUrl: Schema.optionalKey(TrimmedString), + projectId: Schema.optionalKey(TrimmedString), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -545,6 +645,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + sazabi: Schema.optionalKey(SazabiSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual