diff options
Diffstat (limited to 'packages/sdk/src')
47 files changed, 4935 insertions, 0 deletions
diff --git a/packages/sdk/src/api-promise.ts b/packages/sdk/src/api-promise.ts new file mode 100644 index 000000000..8c775ee69 --- /dev/null +++ b/packages/sdk/src/api-promise.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/api-promise instead */ +export * from './core/api-promise'; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts new file mode 100644 index 000000000..c514ca877 --- /dev/null +++ b/packages/sdk/src/client.ts @@ -0,0 +1,862 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { RequestInit, RequestInfo, BodyInit } from './internal/builtin-types'; +import type { HTTPMethod, PromiseOrValue, MergedRequestInit, FinalizedRequestInit } from './internal/types'; +import { uuid4 } from './internal/utils/uuid'; +import { validatePositiveInteger, isAbsoluteURL, safeJSON } from './internal/utils/values'; +import { sleep } from './internal/utils/sleep'; +export type { Logger, LogLevel } from './internal/utils/log'; +import { castToError, isAbortError } from './internal/errors'; +import type { APIResponseProps } from './internal/parse'; +import { getPlatformHeaders } from './internal/detect-platform'; +import * as Shims from './internal/shims'; +import * as Opts from './internal/request-options'; +import { VERSION } from './version'; +import * as Errors from './core/error'; +import * as Uploads from './core/uploads'; +import * as API from './resources/index'; +import { APIPromise } from './core/api-promise'; +import { + App, + AppInitResponse, + AppLogParams, + AppLogResponse, + AppModesResponse, + AppProvidersResponse, + AppResource, + Mode, + Model, + Provider, +} from './resources/app'; +import { + Config, + ConfigResource, + KeybindsConfig, + McpLocalConfig, + McpRemoteConfig, + ModeConfig, +} from './resources/config'; +import { Event, EventListResponse } from './resources/event'; +import { File, FileReadParams, FileReadResponse, FileResource, FileStatusResponse } from './resources/file'; +import { + Find, + FindFilesParams, + FindFilesResponse, + FindSymbolsParams, + FindSymbolsResponse, + FindTextParams, + FindTextResponse, + Match, + Symbol, +} from './resources/find'; +import { + AssistantMessage, + FilePart, + FilePartInput, + FilePartSource, + FilePartSourceText, + FileSource, + Message, + Part, + Session, + SessionAbortResponse, + SessionChatParams, + SessionDeleteResponse, + SessionInitParams, + SessionInitResponse, + SessionListResponse, + SessionMessagesResponse, + SessionResource, + SessionSummarizeParams, + SessionSummarizeResponse, + SnapshotPart, + StepFinishPart, + StepStartPart, + SymbolSource, + TextPart, + TextPartInput, + ToolPart, + ToolStateCompleted, + ToolStateError, + ToolStatePending, + ToolStateRunning, + UserMessage, +} from './resources/session'; +import { Tui, TuiAppendPromptParams, TuiAppendPromptResponse, TuiOpenHelpResponse } from './resources/tui'; +import { type Fetch } from './internal/builtin-types'; +import { HeadersLike, NullableHeaders, buildHeaders } from './internal/headers'; +import { FinalRequestOptions, RequestOptions } from './internal/request-options'; +import { readEnv } from './internal/utils/env'; +import { + type LogLevel, + type Logger, + formatRequestDetails, + loggerFor, + parseLogLevel, +} from './internal/utils/log'; +import { isEmptyObj } from './internal/utils/values'; + +export interface ClientOptions { + /** + * Override the default base URL for the API, e.g., "https://api.example.com/v2/" + * + * Defaults to process.env['OPENCODE_BASE_URL']. + */ + baseURL?: string | null | undefined; + + /** + * The maximum amount of time (in milliseconds) that the client should wait for a response + * from the server before timing out a single request. + * + * Note that request timeouts are retried by default, so in a worst-case scenario you may wait + * much longer than this timeout before the promise succeeds or fails. + * + * @unit milliseconds + */ + timeout?: number | undefined; + /** + * Additional `RequestInit` options to be passed to `fetch` calls. + * Properties will be overridden by per-request `fetchOptions`. + */ + fetchOptions?: MergedRequestInit | undefined; + + /** + * Specify a custom `fetch` function implementation. + * + * If not provided, we expect that `fetch` is defined globally. + */ + fetch?: Fetch | undefined; + + /** + * The maximum number of times that the client will retry a request in case of a + * temporary failure, like a network error or a 5XX error from the server. + * + * @default 2 + */ + maxRetries?: number | undefined; + + /** + * Default headers to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * header to `null` in request options. + */ + defaultHeaders?: HeadersLike | undefined; + + /** + * Default query parameters to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * param to `undefined` in request options. + */ + defaultQuery?: Record<string, string | undefined> | undefined; + + /** + * Set the log level. + * + * Defaults to process.env['OPENCODE_LOG'] or 'warn' if it isn't set. + */ + logLevel?: LogLevel | undefined; + + /** + * Set the logger. + * + * Defaults to globalThis.console. + */ + logger?: Logger | undefined; +} + +/** + * API Client for interfacing with the Opencode API. + */ +export class Opencode { + baseURL: string; + maxRetries: number; + timeout: number; + logger: Logger | undefined; + logLevel: LogLevel | undefined; + fetchOptions: MergedRequestInit | undefined; + + private fetch: Fetch; + #encoder: Opts.RequestEncoder; + protected idempotencyHeader?: string; + private _options: ClientOptions; + + /** + * API Client for interfacing with the Opencode API. + * + * @param {string} [opts.baseURL=process.env['OPENCODE_BASE_URL'] ?? http://localhost:54321] - Override the default base URL for the API. + * @param {number} [opts.timeout=1 minute] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out. + * @param {MergedRequestInit} [opts.fetchOptions] - Additional `RequestInit` options to be passed to `fetch` calls. + * @param {Fetch} [opts.fetch] - Specify a custom `fetch` function implementation. + * @param {number} [opts.maxRetries=2] - The maximum number of times the client will retry a request. + * @param {HeadersLike} opts.defaultHeaders - Default headers to include with every request to the API. + * @param {Record<string, string | undefined>} opts.defaultQuery - Default query parameters to include with every request to the API. + */ + constructor({ baseURL = readEnv('OPENCODE_BASE_URL'), ...opts }: ClientOptions = {}) { + const options: ClientOptions = { + ...opts, + baseURL: baseURL || `http://localhost:54321`, + }; + + this.baseURL = options.baseURL!; + this.timeout = options.timeout ?? Opencode.DEFAULT_TIMEOUT /* 1 minute */; + this.logger = options.logger ?? console; + const defaultLogLevel = 'warn'; + // Set default logLevel early so that we can log a warning in parseLogLevel. + this.logLevel = defaultLogLevel; + this.logLevel = + parseLogLevel(options.logLevel, 'ClientOptions.logLevel', this) ?? + parseLogLevel(readEnv('OPENCODE_LOG'), "process.env['OPENCODE_LOG']", this) ?? + defaultLogLevel; + this.fetchOptions = options.fetchOptions; + this.maxRetries = options.maxRetries ?? 2; + this.fetch = options.fetch ?? Shims.getDefaultFetch(); + this.#encoder = Opts.FallbackEncoder; + + this._options = options; + } + + /** + * Create a new client instance re-using the same options given to the current client with optional overriding. + */ + withOptions(options: Partial<ClientOptions>): this { + const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ + ...this._options, + baseURL: this.baseURL, + maxRetries: this.maxRetries, + timeout: this.timeout, + logger: this.logger, + logLevel: this.logLevel, + fetch: this.fetch, + fetchOptions: this.fetchOptions, + ...options, + }); + return client; + } + + /** + * Check whether the base URL is set to its default. + */ + #baseURLOverridden(): boolean { + return this.baseURL !== 'http://localhost:54321'; + } + + protected defaultQuery(): Record<string, string | undefined> | undefined { + return this._options.defaultQuery; + } + + protected validateHeaders({ values, nulls }: NullableHeaders) { + return; + } + + /** + * Basic re-implementation of `qs.stringify` for primitive types. + */ + protected stringifyQuery(query: Record<string, unknown>): string { + return Object.entries(query) + .filter(([_, value]) => typeof value !== 'undefined') + .map(([key, value]) => { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + } + if (value === null) { + return `${encodeURIComponent(key)}=`; + } + throw new Errors.OpencodeError( + `Cannot stringify type ${typeof value}; Expected string, number, boolean, or null. If you need to pass nested query parameters, you can manually encode them, e.g. { query: { 'foo[key1]': value1, 'foo[key2]': value2 } }, and please open a GitHub issue requesting better support for your use case.`, + ); + }) + .join('&'); + } + + private getUserAgent(): string { + return `${this.constructor.name}/JS ${VERSION}`; + } + + protected defaultIdempotencyKey(): string { + return `stainless-node-retry-${uuid4()}`; + } + + protected makeStatusError( + status: number, + error: Object, + message: string | undefined, + headers: Headers, + ): Errors.APIError { + return Errors.APIError.generate(status, error, message, headers); + } + + buildURL( + path: string, + query: Record<string, unknown> | null | undefined, + defaultBaseURL?: string | undefined, + ): string { + const baseURL = (!this.#baseURLOverridden() && defaultBaseURL) || this.baseURL; + const url = + isAbsoluteURL(path) ? + new URL(path) + : new URL(baseURL + (baseURL.endsWith('/') && path.startsWith('/') ? path.slice(1) : path)); + + const defaultQuery = this.defaultQuery(); + if (!isEmptyObj(defaultQuery)) { + query = { ...defaultQuery, ...query }; + } + + if (typeof query === 'object' && query && !Array.isArray(query)) { + url.search = this.stringifyQuery(query as Record<string, unknown>); + } + + return url.toString(); + } + + /** + * Used as a callback for mutating the given `FinalRequestOptions` object. + */ + protected async prepareOptions(options: FinalRequestOptions): Promise<void> {} + + /** + * Used as a callback for mutating the given `RequestInit` object. + * + * This is useful for cases where you want to add certain headers based off of + * the request properties, e.g. `method` or `url`. + */ + protected async prepareRequest( + request: RequestInit, + { url, options }: { url: string; options: FinalRequestOptions }, + ): Promise<void> {} + + get<Rsp>(path: string, opts?: PromiseOrValue<RequestOptions>): APIPromise<Rsp> { + return this.methodRequest('get', path, opts); + } + + post<Rsp>(path: string, opts?: PromiseOrValue<RequestOptions>): APIPromise<Rsp> { + return this.methodRequest('post', path, opts); + } + + patch<Rsp>(path: string, opts?: PromiseOrValue<RequestOptions>): APIPromise<Rsp> { + return this.methodRequest('patch', path, opts); + } + + put<Rsp>(path: string, opts?: PromiseOrValue<RequestOptions>): APIPromise<Rsp> { + return this.methodRequest('put', path, opts); + } + + delete<Rsp>(path: string, opts?: PromiseOrValue<RequestOptions>): APIPromise<Rsp> { + return this.methodRequest('delete', path, opts); + } + + private methodRequest<Rsp>( + method: HTTPMethod, + path: string, + opts?: PromiseOrValue<RequestOptions>, + ): APIPromise<Rsp> { + return this.request( + Promise.resolve(opts).then((opts) => { + return { method, path, ...opts }; + }), + ); + } + + request<Rsp>( + options: PromiseOrValue<FinalRequestOptions>, + remainingRetries: number | null = null, + ): APIPromise<Rsp> { + return new APIPromise(this, this.makeRequest(options, remainingRetries, undefined)); + } + + private async makeRequest( + optionsInput: PromiseOrValue<FinalRequestOptions>, + retriesRemaining: number | null, + retryOfRequestLogID: string | undefined, + ): Promise<APIResponseProps> { + const options = await optionsInput; + const maxRetries = options.maxRetries ?? this.maxRetries; + if (retriesRemaining == null) { + retriesRemaining = maxRetries; + } + + await this.prepareOptions(options); + + const { req, url, timeout } = await this.buildRequest(options, { + retryCount: maxRetries - retriesRemaining, + }); + + await this.prepareRequest(req, { url, options }); + + /** Not an API request ID, just for correlating local log entries. */ + const requestLogID = 'log_' + ((Math.random() * (1 << 24)) | 0).toString(16).padStart(6, '0'); + const retryLogStr = retryOfRequestLogID === undefined ? '' : `, retryOf: ${retryOfRequestLogID}`; + const startTime = Date.now(); + + loggerFor(this).debug( + `[${requestLogID}] sending request`, + formatRequestDetails({ + retryOfRequestLogID, + method: options.method, + url, + options, + headers: req.headers, + }), + ); + + if (options.signal?.aborted) { + throw new Errors.APIUserAbortError(); + } + + const controller = new AbortController(); + const response = await this.fetchWithTimeout(url, req, timeout, controller).catch(castToError); + const headersTime = Date.now(); + + if (response instanceof Error) { + const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; + if (options.signal?.aborted) { + throw new Errors.APIUserAbortError(); + } + // detect native connection timeout errors + // deno throws "TypeError: error sending request for url (https://example/): client error (Connect): tcp connect error: Operation timed out (os error 60): Operation timed out (os error 60)" + // undici throws "TypeError: fetch failed" with cause "ConnectTimeoutError: Connect Timeout Error (attempted address: example:443, timeout: 1ms)" + // others do not provide enough information to distinguish timeouts from other connection errors + const isTimeout = + isAbortError(response) || + /timed? ?out/i.test(String(response) + ('cause' in response ? String(response.cause) : '')); + if (retriesRemaining) { + loggerFor(this).info( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - ${retryMessage}`, + ); + loggerFor(this).debug( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url, + durationMs: headersTime - startTime, + message: response.message, + }), + ); + return this.retryRequest(options, retriesRemaining, retryOfRequestLogID ?? requestLogID); + } + loggerFor(this).info( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - error; no more retries left`, + ); + loggerFor(this).debug( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (error; no more retries left)`, + formatRequestDetails({ + retryOfRequestLogID, + url, + durationMs: headersTime - startTime, + message: response.message, + }), + ); + if (isTimeout) { + throw new Errors.APIConnectionTimeoutError(); + } + throw new Errors.APIConnectionError({ cause: response }); + } + + const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${url} ${ + response.ok ? 'succeeded' : 'failed' + } with status ${response.status} in ${headersTime - startTime}ms`; + + if (!response.ok) { + const shouldRetry = await this.shouldRetry(response); + if (retriesRemaining && shouldRetry) { + const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; + + // We don't need the body of this response. + await Shims.CancelReadableStream(response.body); + loggerFor(this).info(`${responseInfo} - ${retryMessage}`); + loggerFor(this).debug( + `[${requestLogID}] response error (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + durationMs: headersTime - startTime, + }), + ); + return this.retryRequest( + options, + retriesRemaining, + retryOfRequestLogID ?? requestLogID, + response.headers, + ); + } + + const retryMessage = shouldRetry ? `error; no more retries left` : `error; not retryable`; + + loggerFor(this).info(`${responseInfo} - ${retryMessage}`); + + const errText = await response.text().catch((err: any) => castToError(err).message); + const errJSON = safeJSON(errText); + const errMessage = errJSON ? undefined : errText; + + loggerFor(this).debug( + `[${requestLogID}] response error (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + message: errMessage, + durationMs: Date.now() - startTime, + }), + ); + + const err = this.makeStatusError(response.status, errJSON, errMessage, response.headers); + throw err; + } + + loggerFor(this).info(responseInfo); + loggerFor(this).debug( + `[${requestLogID}] response start`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + durationMs: headersTime - startTime, + }), + ); + + return { response, options, controller, requestLogID, retryOfRequestLogID, startTime }; + } + + async fetchWithTimeout( + url: RequestInfo, + init: RequestInit | undefined, + ms: number, + controller: AbortController, + ): Promise<Response> { + const { signal, method, ...options } = init || {}; + if (signal) signal.addEventListener('abort', () => controller.abort()); + + const timeout = setTimeout(() => controller.abort(), ms); + + const isReadableBody = + ((globalThis as any).ReadableStream && options.body instanceof (globalThis as any).ReadableStream) || + (typeof options.body === 'object' && options.body !== null && Symbol.asyncIterator in options.body); + + const fetchOptions: RequestInit = { + signal: controller.signal as any, + ...(isReadableBody ? { duplex: 'half' } : {}), + method: 'GET', + ...options, + }; + if (method) { + // Custom methods like 'patch' need to be uppercased + // See https://github.com/nodejs/undici/issues/2294 + fetchOptions.method = method.toUpperCase(); + } + + try { + // use undefined this binding; fetch errors if bound to something else in browser/cloudflare + return await this.fetch.call(undefined, url, fetchOptions); + } finally { + clearTimeout(timeout); + } + } + + private async shouldRetry(response: Response): Promise<boolean> { + // Note this is not a standard header. + const shouldRetryHeader = response.headers.get('x-should-retry'); + + // If the server explicitly says whether or not to retry, obey. + if (shouldRetryHeader === 'true') return true; + if (shouldRetryHeader === 'false') return false; + + // Retry on request timeouts. + if (response.status === 408) return true; + + // Retry on lock timeouts. + if (response.status === 409) return true; + + // Retry on rate limits. + if (response.status === 429) return true; + + // Retry internal errors. + if (response.status >= 500) return true; + + return false; + } + + private async retryRequest( + options: FinalRequestOptions, + retriesRemaining: number, + requestLogID: string, + responseHeaders?: Headers | undefined, + ): Promise<APIResponseProps> { + let timeoutMillis: number | undefined; + + // Note the `retry-after-ms` header may not be standard, but is a good idea and we'd like proactive support for it. + const retryAfterMillisHeader = responseHeaders?.get('retry-after-ms'); + if (retryAfterMillisHeader) { + const timeoutMs = parseFloat(retryAfterMillisHeader); + if (!Number.isNaN(timeoutMs)) { + timeoutMillis = timeoutMs; + } + } + + // About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + const retryAfterHeader = responseHeaders?.get('retry-after'); + if (retryAfterHeader && !timeoutMillis) { + const timeoutSeconds = parseFloat(retryAfterHeader); + if (!Number.isNaN(timeoutSeconds)) { + timeoutMillis = timeoutSeconds * 1000; + } else { + timeoutMillis = Date.parse(retryAfterHeader) - Date.now(); + } + } + + // If the API asks us to wait a certain amount of time (and it's a reasonable amount), + // just do what it says, but otherwise calculate a default + if (!(timeoutMillis && 0 <= timeoutMillis && timeoutMillis < 60 * 1000)) { + const maxRetries = options.maxRetries ?? this.maxRetries; + timeoutMillis = this.calculateDefaultRetryTimeoutMillis(retriesRemaining, maxRetries); + } + await sleep(timeoutMillis); + + return this.makeRequest(options, retriesRemaining - 1, requestLogID); + } + + private calculateDefaultRetryTimeoutMillis(retriesRemaining: number, maxRetries: number): number { + const initialRetryDelay = 0.5; + const maxRetryDelay = 8.0; + + const numRetries = maxRetries - retriesRemaining; + + // Apply exponential backoff, but not more than the max. + const sleepSeconds = Math.min(initialRetryDelay * Math.pow(2, numRetries), maxRetryDelay); + + // Apply some jitter, take up to at most 25 percent of the retry time. + const jitter = 1 - Math.random() * 0.25; + + return sleepSeconds * jitter * 1000; + } + + async buildRequest( + inputOptions: FinalRequestOptions, + { retryCount = 0 }: { retryCount?: number } = {}, + ): Promise<{ req: FinalizedRequestInit; url: string; timeout: number }> { + const options = { ...inputOptions }; + const { method, path, query, defaultBaseURL } = options; + + const url = this.buildURL(path!, query as Record<string, unknown>, defaultBaseURL); + if ('timeout' in options) validatePositiveInteger('timeout', options.timeout); + options.timeout = options.timeout ?? this.timeout; + const { bodyHeaders, body } = this.buildBody({ options }); + const reqHeaders = await this.buildHeaders({ options: inputOptions, method, bodyHeaders, retryCount }); + + const req: FinalizedRequestInit = { + method, + headers: reqHeaders, + ...(options.signal && { signal: options.signal }), + ...((globalThis as any).ReadableStream && + body instanceof (globalThis as any).ReadableStream && { duplex: 'half' }), + ...(body && { body }), + ...((this.fetchOptions as any) ?? {}), + ...((options.fetchOptions as any) ?? {}), + }; + + return { req, url, timeout: options.timeout }; + } + + private async buildHeaders({ + options, + method, + bodyHeaders, + retryCount, + }: { + options: FinalRequestOptions; + method: HTTPMethod; + bodyHeaders: HeadersLike; + retryCount: number; + }): Promise<Headers> { + let idempotencyHeaders: HeadersLike = {}; + if (this.idempotencyHeader && method !== 'get') { + if (!options.idempotencyKey) options.idempotencyKey = this.defaultIdempotencyKey(); + idempotencyHeaders[this.idempotencyHeader] = options.idempotencyKey; + } + + const headers = buildHeaders([ + idempotencyHeaders, + { + Accept: 'application/json', + 'User-Agent': this.getUserAgent(), + 'X-Stainless-Retry-Count': String(retryCount), + ...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}), + ...getPlatformHeaders(), + }, + this._options.defaultHeaders, + bodyHeaders, + options.headers, + ]); + + this.validateHeaders(headers); + + return headers.values; + } + + private buildBody({ options: { body, headers: rawHeaders } }: { options: FinalRequestOptions }): { + bodyHeaders: HeadersLike; + body: BodyInit | undefined; + } { + if (!body) { + return { bodyHeaders: undefined, body: undefined }; + } + const headers = buildHeaders([rawHeaders]); + if ( + // Pass raw type verbatim + ArrayBuffer.isView(body) || + body instanceof ArrayBuffer || + body instanceof DataView || + (typeof body === 'string' && + // Preserve legacy string encoding behavior for now + headers.values.has('content-type')) || + // `Blob` is superset of `File` + body instanceof Blob || + // `FormData` -> `multipart/form-data` + body instanceof FormData || + // `URLSearchParams` -> `application/x-www-form-urlencoded` + body instanceof URLSearchParams || + // Send chunked stream (each chunk has own `length`) + ((globalThis as any).ReadableStream && body instanceof (globalThis as any).ReadableStream) + ) { + return { bodyHeaders: undefined, body: body as BodyInit }; + } else if ( + typeof body === 'object' && + (Symbol.asyncIterator in body || + (Symbol.iterator in body && 'next' in body && typeof body.next === 'function')) + ) { + return { bodyHeaders: undefined, body: Shims.ReadableStreamFrom(body as AsyncIterable<Uint8Array>) }; + } else { + return this.#encoder({ body, headers }); + } + } + + static Opencode = this; + static DEFAULT_TIMEOUT = 60000; // 1 minute + + static OpencodeError = Errors.OpencodeError; + static APIError = Errors.APIError; + static APIConnectionError = Errors.APIConnectionError; + static APIConnectionTimeoutError = Errors.APIConnectionTimeoutError; + static APIUserAbortError = Errors.APIUserAbortError; + static NotFoundError = Errors.NotFoundError; + static ConflictError = Errors.ConflictError; + static RateLimitError = Errors.RateLimitError; + static BadRequestError = Errors.BadRequestError; + static AuthenticationError = Errors.AuthenticationError; + static InternalServerError = Errors.InternalServerError; + static PermissionDeniedError = Errors.PermissionDeniedError; + static UnprocessableEntityError = Errors.UnprocessableEntityError; + + static toFile = Uploads.toFile; + + event: API.Event = new API.Event(this); + app: API.AppResource = new API.AppResource(this); + find: API.Find = new API.Find(this); + file: API.FileResource = new API.FileResource(this); + config: API.ConfigResource = new API.ConfigResource(this); + session: API.SessionResource = new API.SessionResource(this); + tui: API.Tui = new API.Tui(this); +} +Opencode.Event = Event; +Opencode.AppResource = AppResource; +Opencode.Find = Find; +Opencode.FileResource = FileResource; +Opencode.ConfigResource = ConfigResource; +Opencode.SessionResource = SessionResource; +Opencode.Tui = Tui; +export declare namespace Opencode { + export type RequestOptions = Opts.RequestOptions; + + export { Event as Event, type EventListResponse as EventListResponse }; + + export { + AppResource as AppResource, + type App as App, + type Mode as Mode, + type Model as Model, + type Provider as Provider, + type AppInitResponse as AppInitResponse, + type AppLogResponse as AppLogResponse, + type AppModesResponse as AppModesResponse, + type AppProvidersResponse as AppProvidersResponse, + type AppLogParams as AppLogParams, + }; + + export { + Find as Find, + type Match as Match, + type Symbol as Symbol, + type FindFilesResponse as FindFilesResponse, + type FindSymbolsResponse as FindSymbolsResponse, + type FindTextResponse as FindTextResponse, + type FindFilesParams as FindFilesParams, + type FindSymbolsParams as FindSymbolsParams, + type FindTextParams as FindTextParams, + }; + + export { + FileResource as FileResource, + type File as File, + type FileReadResponse as FileReadResponse, + type FileStatusResponse as FileStatusResponse, + type FileReadParams as FileReadParams, + }; + + export { + ConfigResource as ConfigResource, + type Config as Config, + type KeybindsConfig as KeybindsConfig, + type McpLocalConfig as McpLocalConfig, + type McpRemoteConfig as McpRemoteConfig, + type ModeConfig as ModeConfig, + }; + + export { + SessionResource as SessionResource, + type AssistantMessage as AssistantMessage, + type FilePart as FilePart, + type FilePartInput as FilePartInput, + type FilePartSource as FilePartSource, + type FilePartSourceText as FilePartSourceText, + type FileSource as FileSource, + type Message as Message, + type Part as Part, + type Session as Session, + type SnapshotPart as SnapshotPart, + type StepFinishPart as StepFinishPart, + type StepStartPart as StepStartPart, + type SymbolSource as SymbolSource, + type TextPart as TextPart, + type TextPartInput as TextPartInput, + type ToolPart as ToolPart, + type ToolStateCompleted as ToolStateCompleted, + type ToolStateError as ToolStateError, + type ToolStatePending as ToolStatePending, + type ToolStateRunning as ToolStateRunning, + type UserMessage as UserMessage, + type SessionListResponse as SessionListResponse, + type SessionDeleteResponse as SessionDeleteResponse, + type SessionAbortResponse as SessionAbortResponse, + type SessionInitResponse as SessionInitResponse, + type SessionMessagesResponse as SessionMessagesResponse, + type SessionSummarizeResponse as SessionSummarizeResponse, + type SessionChatParams as SessionChatParams, + type SessionInitParams as SessionInitParams, + type SessionSummarizeParams as SessionSummarizeParams, + }; + + export { + Tui as Tui, + type TuiAppendPromptResponse as TuiAppendPromptResponse, + type TuiOpenHelpResponse as TuiOpenHelpResponse, + type TuiAppendPromptParams as TuiAppendPromptParams, + }; + + export type MessageAbortedError = API.MessageAbortedError; + export type ProviderAuthError = API.ProviderAuthError; + export type UnknownError = API.UnknownError; +} diff --git a/packages/sdk/src/core/README.md b/packages/sdk/src/core/README.md new file mode 100644 index 000000000..485fce861 --- /dev/null +++ b/packages/sdk/src/core/README.md @@ -0,0 +1,3 @@ +# `core` + +This directory holds public modules implementing non-resource-specific SDK functionality. diff --git a/packages/sdk/src/core/api-promise.ts b/packages/sdk/src/core/api-promise.ts new file mode 100644 index 000000000..fc1c6dd76 --- /dev/null +++ b/packages/sdk/src/core/api-promise.ts @@ -0,0 +1,92 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { type Opencode } from '../client'; + +import { type PromiseOrValue } from '../internal/types'; +import { APIResponseProps, defaultParseResponse } from '../internal/parse'; + +/** + * A subclass of `Promise` providing additional helper methods + * for interacting with the SDK. + */ +export class APIPromise<T> extends Promise<T> { + private parsedPromise: Promise<T> | undefined; + #client: Opencode; + + constructor( + client: Opencode, + private responsePromise: Promise<APIResponseProps>, + private parseResponse: ( + client: Opencode, + props: APIResponseProps, + ) => PromiseOrValue<T> = defaultParseResponse, + ) { + super((resolve) => { + // this is maybe a bit weird but this has to be a no-op to not implicitly + // parse the response body; instead .then, .catch, .finally are overridden + // to parse the response + resolve(null as any); + }); + this.#client = client; + } + + _thenUnwrap<U>(transform: (data: T, props: APIResponseProps) => U): APIPromise<U> { + return new APIPromise(this.#client, this.responsePromise, async (client, props) => + transform(await this.parseResponse(client, props), props), + ); + } + + /** + * Gets the raw `Response` instance instead of parsing the response + * data. + * + * If you want to parse the response body but still get the `Response` + * instance, you can use {@link withResponse()}. + * + * 👋 Getting the wrong TypeScript type for `Response`? + * Try setting `"moduleResolution": "NodeNext"` or add `"lib": ["DOM"]` + * to your `tsconfig.json`. + */ + asResponse(): Promise<Response> { + return this.responsePromise.then((p) => p.response); + } + + /** + * Gets the parsed response data and the raw `Response` instance. + * + * If you just want to get the raw `Response` instance without parsing it, + * you can use {@link asResponse()}. + * + * 👋 Getting the wrong TypeScript type for `Response`? + * Try setting `"moduleResolution": "NodeNext"` or add `"lib": ["DOM"]` + * to your `tsconfig.json`. + */ + async withResponse(): Promise<{ data: T; response: Response }> { + const [data, response] = await Promise.all([this.parse(), this.asResponse()]); + return { data, response }; + } + + private parse(): Promise<T> { + if (!this.parsedPromise) { + this.parsedPromise = this.responsePromise.then((data) => this.parseResponse(this.#client, data)); + } + return this.parsedPromise; + } + + override then<TResult1 = T, TResult2 = never>( + onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null, + ): Promise<TResult1 | TResult2> { + return this.parse().then(onfulfilled, onrejected); + } + + override catch<TResult = never>( + onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null, + ): Promise<T | TResult> { + return this.parse().catch(onrejected); + } + + override finally(onfinally?: (() => void) | undefined | null): Promise<T> { + return this.parse().finally(onfinally); + } +} diff --git a/packages/sdk/src/core/error.ts b/packages/sdk/src/core/error.ts new file mode 100644 index 000000000..44698c739 --- /dev/null +++ b/packages/sdk/src/core/error.ts @@ -0,0 +1,130 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { castToError } from '../internal/errors'; + +export class OpencodeError extends Error {} + +export class APIError< + TStatus extends number | undefined = number | undefined, + THeaders extends Headers | undefined = Headers | undefined, + TError extends Object | undefined = Object | undefined, +> extends OpencodeError { + /** HTTP status for the response that caused the error */ + readonly status: TStatus; + /** HTTP headers for the response that caused the error */ + readonly headers: THeaders; + /** JSON body of the response that caused the error */ + readonly error: TError; + + constructor(status: TStatus, error: TError, message: string | undefined, headers: THeaders) { + super(`${APIError.makeMessage(status, error, message)}`); + this.status = status; + this.headers = headers; + this.error = error; + } + + private static makeMessage(status: number | undefined, error: any, message: string | undefined) { + const msg = + error?.message ? + typeof error.message === 'string' ? + error.message + : JSON.stringify(error.message) + : error ? JSON.stringify(error) + : message; + + if (status && msg) { + return `${status} ${msg}`; + } + if (status) { + return `${status} status code (no body)`; + } + if (msg) { + return msg; + } + return '(no status code or body)'; + } + + static generate( + status: number | undefined, + errorResponse: Object | undefined, + message: string | undefined, + headers: Headers | undefined, + ): APIError { + if (!status || !headers) { + return new APIConnectionError({ message, cause: castToError(errorResponse) }); + } + + const error = errorResponse as Record<string, any>; + + if (status === 400) { + return new BadRequestError(status, error, message, headers); + } + + if (status === 401) { + return new AuthenticationError(status, error, message, headers); + } + + if (status === 403) { + return new PermissionDeniedError(status, error, message, headers); + } + + if (status === 404) { + return new NotFoundError(status, error, message, headers); + } + + if (status === 409) { + return new ConflictError(status, error, message, headers); + } + + if (status === 422) { + return new UnprocessableEntityError(status, error, message, headers); + } + + if (status === 429) { + return new RateLimitError(status, error, message, headers); + } + + if (status >= 500) { + return new InternalServerError(status, error, message, headers); + } + + return new APIError(status, error, message, headers); + } +} + +export class APIUserAbortError extends APIError<undefined, undefined, undefined> { + constructor({ message }: { message?: string } = {}) { + super(undefined, undefined, message || 'Request was aborted.', undefined); + } +} + +export class APIConnectionError extends APIError<undefined, undefined, undefined> { + constructor({ message, cause }: { message?: string | undefined; cause?: Error | undefined }) { + super(undefined, undefined, message || 'Connection error.', undefined); + // in some environments the 'cause' property is already declared + // @ts-ignore + if (cause) this.cause = cause; + } +} + +export class APIConnectionTimeoutError extends APIConnectionError { + constructor({ message }: { message?: string } = {}) { + super({ message: message ?? 'Request timed out.' }); + } +} + +export class BadRequestError extends APIError<400, Headers> {} + +export class AuthenticationError extends APIError<401, Headers> {} + +export class PermissionDeniedError extends APIError<403, Headers> {} + +export class NotFoundError extends APIError<404, Headers> {} + +export class ConflictError extends APIError<409, Headers> {} + +export class UnprocessableEntityError extends APIError<422, Headers> {} + +export class RateLimitError extends APIError<429, Headers> {} + +export class InternalServerError extends APIError<number, Headers> {} diff --git a/packages/sdk/src/core/resource.ts b/packages/sdk/src/core/resource.ts new file mode 100644 index 000000000..ccf9032e4 --- /dev/null +++ b/packages/sdk/src/core/resource.ts @@ -0,0 +1,11 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { Opencode } from '../client'; + +export abstract class APIResource { + protected _client: Opencode; + + constructor(client: Opencode) { + this._client = client; + } +} diff --git a/packages/sdk/src/core/streaming.ts b/packages/sdk/src/core/streaming.ts new file mode 100644 index 000000000..33dcfd126 --- /dev/null +++ b/packages/sdk/src/core/streaming.ts @@ -0,0 +1,315 @@ +import { OpencodeError } from './error'; +import { type ReadableStream } from '../internal/shim-types'; +import { makeReadableStream } from '../internal/shims'; +import { findDoubleNewlineIndex, LineDecoder } from '../internal/decoders/line'; +import { ReadableStreamToAsyncIterable } from '../internal/shims'; +import { isAbortError } from '../internal/errors'; +import { encodeUTF8 } from '../internal/utils/bytes'; +import { loggerFor } from '../internal/utils/log'; +import type { Opencode } from '../client'; + +type Bytes = string | ArrayBuffer | Uint8Array | null | undefined; + +export type ServerSentEvent = { + event: string | null; + data: string; + raw: string[]; +}; + +export class Stream<Item> implements AsyncIterable<Item> { + controller: AbortController; + #client: Opencode | undefined; + + constructor( + private iterator: () => AsyncIterator<Item>, + controller: AbortController, + client?: Opencode, + ) { + this.controller = controller; + this.#client = client; + } + + static fromSSEResponse<Item>( + response: Response, + controller: AbortController, + client?: Opencode, + ): Stream<Item> { + let consumed = false; + const logger = client ? loggerFor(client) : console; + + async function* iterator(): AsyncIterator<Item, any, undefined> { + if (consumed) { + throw new OpencodeError('Cannot iterate over a consumed stream, use `.tee()` to split the stream.'); + } + consumed = true; + let done = false; + try { + for await (const sse of _iterSSEMessages(response, controller)) { + try { + yield JSON.parse(sse.data); + } catch (e) { + logger.error(`Could not parse message into JSON:`, sse.data); + logger.error(`From chunk:`, sse.raw); + throw e; + } + } + done = true; + } catch (e) { + // If the user calls `stream.controller.abort()`, we should exit without throwing. + if (isAbortError(e)) return; + throw e; + } finally { + // If the user `break`s, abort the ongoing request. + if (!done) controller.abort(); + } + } + + return new Stream(iterator, controller, client); + } + + /** + * Generates a Stream from a newline-separated ReadableStream + * where each item is a JSON value. + */ + static fromReadableStream<Item>( + readableStream: ReadableStream, + controller: AbortController, + client?: Opencode, + ): Stream<Item> { + let consumed = false; + + async function* iterLines(): AsyncGenerator<string, void, unknown> { + const lineDecoder = new LineDecoder(); + + const iter = ReadableStreamToAsyncIterable<Bytes>(readableStream); + for await (const chunk of iter) { + for (const line of lineDecoder.decode(chunk)) { + yield line; + } + } + + for (const line of lineDecoder.flush()) { + yield line; + } + } + + async function* iterator(): AsyncIterator<Item, any, undefined> { + if (consumed) { + throw new OpencodeError('Cannot iterate over a consumed stream, use `.tee()` to split the stream.'); + } + consumed = true; + let done = false; + try { + for await (const line of iterLines()) { + if (done) continue; + if (line) yield JSON.parse(line); + } + done = true; + } catch (e) { + // If the user calls `stream.controller.abort()`, we should exit without throwing. + if (isAbortError(e)) return; + throw e; + } finally { + // If the user `break`s, abort the ongoing request. + if (!done) controller.abort(); + } + } + + return new Stream(iterator, controller, client); + } + + [Symbol.asyncIterator](): AsyncIterator<Item> { + return this.iterator(); + } + + /** + * Splits the stream into two streams which can be + * independently read from at different speeds. + */ + tee(): [Stream<Item>, Stream<Item>] { + const left: Array<Promise<IteratorResult<Item>>> = []; + const right: Array<Promise<IteratorResult<Item>>> = []; + const iterator = this.iterator(); + + const teeIterator = (queue: Array<Promise<IteratorResult<Item>>>): AsyncIterator<Item> => { + return { + next: () => { + if (queue.length === 0) { + const result = iterator.next(); + left.push(result); + right.push(result); + } + return queue.shift()!; + }, + }; + }; + + return [ + new Stream(() => teeIterator(left), this.controller, this.#client), + new Stream(() => teeIterator(right), this.controller, this.#client), + ]; + } + + /** + * Converts this stream to a newline-separated ReadableStream of + * JSON stringified values in the stream + * which can be turned back into a Stream with `Stream.fromReadableStream()`. + */ + toReadableStream(): ReadableStream { + const self = this; + let iter: AsyncIterator<Item>; + + return makeReadableStream({ + async start() { + iter = self[Symbol.asyncIterator](); + }, + async pull(ctrl: any) { + try { + const { value, done } = await iter.next(); + if (done) return ctrl.close(); + + const bytes = encodeUTF8(JSON.stringify(value) + '\n'); + + ctrl.enqueue(bytes); + } catch (err) { + ctrl.error(err); + } + }, + async cancel() { + await iter.return?.(); + }, + }); + } +} + +export async function* _iterSSEMessages( + response: Response, + controller: AbortController, +): AsyncGenerator<ServerSentEvent, void, unknown> { + if (!response.body) { + controller.abort(); + if ( + typeof (globalThis as any).navigator !== 'undefined' && + (globalThis as any).navigator.product === 'ReactNative' + ) { + throw new OpencodeError( + `The default react-native fetch implementation does not support streaming. Please use expo/fetch: https://docs.expo.dev/versions/latest/sdk/expo/#expofetch-api`, + ); + } + throw new OpencodeError(`Attempted to iterate over a response with no body`); + } + + const sseDecoder = new SSEDecoder(); + const lineDecoder = new LineDecoder(); + + const iter = ReadableStreamToAsyncIterable<Bytes>(response.body); + for await (const sseChunk of iterSSEChunks(iter)) { + for (const line of lineDecoder.decode(sseChunk)) { + const sse = sseDecoder.decode(line); + if (sse) yield sse; + } + } + + for (const line of lineDecoder.flush()) { + const sse = sseDecoder.decode(line); + if (sse) yield sse; + } +} + +/** + * Given an async iterable iterator, iterates over it and yields full + * SSE chunks, i.e. yields when a double new-line is encountered. + */ +async function* iterSSEChunks(iterator: AsyncIterableIterator<Bytes>): AsyncGenerator<Uint8Array> { + let data = new Uint8Array(); + + for await (const chunk of iterator) { + if (chunk == null) { + continue; + } + + const binaryChunk = + chunk instanceof ArrayBuffer ? new Uint8Array(chunk) + : typeof chunk === 'string' ? encodeUTF8(chunk) + : chunk; + + let newData = new Uint8Array(data.length + binaryChunk.length); + newData.set(data); + newData.set(binaryChunk, data.length); + data = newData; + + let patternIndex; + while ((patternIndex = findDoubleNewlineIndex(data)) !== -1) { + yield data.slice(0, patternIndex); + data = data.slice(patternIndex); + } + } + + if (data.length > 0) { + yield data; + } +} + +class SSEDecoder { + private data: string[]; + private event: string | null; + private chunks: string[]; + + constructor() { + this.event = null; + this.data = []; + this.chunks = []; + } + + decode(line: string) { + if (line.endsWith('\r')) { + line = line.substring(0, line.length - 1); + } + + if (!line) { + // empty line and we didn't previously encounter any messages + if (!this.event && !this.data.length) return null; + + const sse: ServerSentEvent = { + event: this.event, + data: this.data.join('\n'), + raw: this.chunks, + }; + + this.event = null; + this.data = []; + this.chunks = []; + + return sse; + } + + this.chunks.push(line); + + if (line.startsWith(':')) { + return null; + } + + let [fieldname, _, value] = partition(line, ':'); + + if (value.startsWith(' ')) { + value = value.substring(1); + } + + if (fieldname === 'event') { + this.event = value; + } else if (fieldname === 'data') { + this.data.push(value); + } + + return null; + } +} + +function partition(str: string, delimiter: string): [string, string, string] { + const index = str.indexOf(delimiter); + if (index !== -1) { + return [str.substring(0, index), delimiter, str.substring(index + delimiter.length)]; + } + + return [str, '', '']; +} diff --git a/packages/sdk/src/core/uploads.ts b/packages/sdk/src/core/uploads.ts new file mode 100644 index 000000000..2882ca6d1 --- /dev/null +++ b/packages/sdk/src/core/uploads.ts @@ -0,0 +1,2 @@ +export { type Uploadable } from '../internal/uploads'; +export { toFile, type ToFileInput } from '../internal/to-file'; diff --git a/packages/sdk/src/error.ts b/packages/sdk/src/error.ts new file mode 100644 index 000000000..fc55f46c0 --- /dev/null +++ b/packages/sdk/src/error.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/error instead */ +export * from './core/error'; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 000000000..43d2f6363 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,22 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export { Opencode as default } from './client'; + +export { type Uploadable, toFile } from './core/uploads'; +export { APIPromise } from './core/api-promise'; +export { Opencode, type ClientOptions } from './client'; +export { + OpencodeError, + APIError, + APIConnectionError, + APIConnectionTimeoutError, + APIUserAbortError, + NotFoundError, + ConflictError, + RateLimitError, + BadRequestError, + AuthenticationError, + InternalServerError, + PermissionDeniedError, + UnprocessableEntityError, +} from './core/error'; diff --git a/packages/sdk/src/internal/README.md b/packages/sdk/src/internal/README.md new file mode 100644 index 000000000..3ef5a25ba --- /dev/null +++ b/packages/sdk/src/internal/README.md @@ -0,0 +1,3 @@ +# `internal` + +The modules in this directory are not importable outside this package and will change between releases. diff --git a/packages/sdk/src/internal/builtin-types.ts b/packages/sdk/src/internal/builtin-types.ts new file mode 100644 index 000000000..c23d3bded --- /dev/null +++ b/packages/sdk/src/internal/builtin-types.ts @@ -0,0 +1,93 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>; + +/** + * An alias to the builtin `RequestInit` type so we can + * easily alias it in import statements if there are name clashes. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit + */ +type _RequestInit = RequestInit; + +/** + * An alias to the builtin `Response` type so we can + * easily alias it in import statements if there are name clashes. + * + * https://developer.mozilla.org/docs/Web/API/Response + */ +type _Response = Response; + +/** + * The type for the first argument to `fetch`. + * + * https://developer.mozilla.org/docs/Web/API/Window/fetch#resource + */ +type _RequestInfo = Request | URL | string; + +/** + * The type for constructing `RequestInit` Headers. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit#setting_headers + */ +type _HeadersInit = RequestInit['headers']; + +/** + * The type for constructing `RequestInit` body. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit#body + */ +type _BodyInit = RequestInit['body']; + +/** + * An alias to the builtin `Array<T>` type so we can + * easily alias it in import statements if there are name clashes. + */ +type _Array<T> = Array<T>; + +/** + * An alias to the builtin `Record<K, T>` type so we can + * easily alias it in import statements if there are name clashes. + */ +type _Record<K extends keyof any, T> = Record<K, T>; + +export type { + _Array as Array, + _BodyInit as BodyInit, + _HeadersInit as HeadersInit, + _Record as Record, + _RequestInfo as RequestInfo, + _RequestInit as RequestInit, + _Response as Response, +}; + +/** + * A copy of the builtin `EndingType` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L27941 + */ +type EndingType = 'native' | 'transparent'; + +/** + * A copy of the builtin `BlobPropertyBag` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L154 + * https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob#options + */ +export interface BlobPropertyBag { + endings?: EndingType; + type?: string; +} + +/** + * A copy of the builtin `FilePropertyBag` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L503 + * https://developer.mozilla.org/en-US/docs/Web/API/File/File#options + */ +export interface FilePropertyBag extends BlobPropertyBag { + lastModified?: number; +} diff --git a/packages/sdk/src/internal/decoders/line.ts b/packages/sdk/src/internal/decoders/line.ts new file mode 100644 index 000000000..b3bfa97cd --- /dev/null +++ b/packages/sdk/src/internal/decoders/line.ts @@ -0,0 +1,135 @@ +import { concatBytes, decodeUTF8, encodeUTF8 } from '../utils/bytes'; + +export type Bytes = string | ArrayBuffer | Uint8Array | null | undefined; + +/** + * A re-implementation of httpx's `LineDecoder` in Python that handles incrementally + * reading lines from text. + * + * https://github.com/encode/httpx/blob/920333ea98118e9cf617f246905d7b202510941c/httpx/_decoders.py#L258 + */ +export class LineDecoder { + // prettier-ignore + static NEWLINE_CHARS = new Set(['\n', '\r']); + static NEWLINE_REGEXP = /\r\n|[\n\r]/g; + + #buffer: Uint8Array; + #carriageReturnIndex: number | null; + + constructor() { + this.#buffer = new Uint8Array(); + this.#carriageReturnIndex = null; + } + + decode(chunk: Bytes): string[] { + if (chunk == null) { + return []; + } + + const binaryChunk = + chunk instanceof ArrayBuffer ? new Uint8Array(chunk) + : typeof chunk === 'string' ? encodeUTF8(chunk) + : chunk; + + this.#buffer = concatBytes([this.#buffer, binaryChunk]); + + const lines: string[] = []; + let patternIndex; + while ((patternIndex = findNewlineIndex(this.#buffer, this.#carriageReturnIndex)) != null) { + if (patternIndex.carriage && this.#carriageReturnIndex == null) { + // skip until we either get a corresponding `\n`, a new `\r` or nothing + this.#carriageReturnIndex = patternIndex.index; + continue; + } + + // we got double \r or \rtext\n + if ( + this.#carriageReturnIndex != null && + (patternIndex.index !== this.#carriageReturnIndex + 1 || patternIndex.carriage) + ) { + lines.push(decodeUTF8(this.#buffer.subarray(0, this.#carriageReturnIndex - 1))); + this.#buffer = this.#buffer.subarray(this.#carriageReturnIndex); + this.#carriageReturnIndex = null; + continue; + } + + const endIndex = + this.#carriageReturnIndex !== null ? patternIndex.preceding - 1 : patternIndex.preceding; + + const line = decodeUTF8(this.#buffer.subarray(0, endIndex)); + lines.push(line); + + this.#buffer = this.#buffer.subarray(patternIndex.index); + this.#carriageReturnIndex = null; + } + + return lines; + } + + flush(): string[] { + if (!this.#buffer.length) { + return []; + } + return this.decode('\n'); + } +} + +/** + * This function searches the buffer for the end patterns, (\r or \n) + * and returns an object with the index preceding the matched newline and the + * index after the newline char. `null` is returned if no new line is found. + * + * ```ts + * findNewLineIndex('abc\ndef') -> { preceding: 2, index: 3 } + * ``` + */ +function findNewlineIndex( + buffer: Uint8Array, + startIndex: number | null, +): { preceding: number; index: number; carriage: boolean } | null { + const newline = 0x0a; // \n + const carriage = 0x0d; // \r + + for (let i = startIndex ?? 0; i < buffer.length; i++) { + if (buffer[i] === newline) { + return { preceding: i, index: i + 1, carriage: false }; + } + + if (buffer[i] === carriage) { + return { preceding: i, index: i + 1, carriage: true }; + } + } + + return null; +} + +export function findDoubleNewlineIndex(buffer: Uint8Array): number { + // This function searches the buffer for the end patterns (\r\r, \n\n, \r\n\r\n) + // and returns the index right after the first occurrence of any pattern, + // or -1 if none of the patterns are found. + const newline = 0x0a; // \n + const carriage = 0x0d; // \r + + for (let i = 0; i < buffer.length - 1; i++) { + if (buffer[i] === newline && buffer[i + 1] === newline) { + // \n\n + return i + 2; + } + if (buffer[i] === carriage && buffer[i + 1] === carriage) { + // \r\r + return i + 2; + } + if ( + buffer[i] === carriage && + buffer[i + 1] === newline && + i + 3 < buffer.length && + buffer[i + 2] === carriage && + buffer[i + 3] === newline + ) { + // \r\n\r\n + return i + 4; + } + } + + return -1; +} diff --git a/packages/sdk/src/internal/detect-platform.ts b/packages/sdk/src/internal/detect-platform.ts new file mode 100644 index 000000000..e82d95c92 --- /dev/null +++ b/packages/sdk/src/internal/detect-platform.ts @@ -0,0 +1,196 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { VERSION } from '../version'; + +export const isRunningInBrowser = () => { + return ( + // @ts-ignore + typeof window !== 'undefined' && + // @ts-ignore + typeof window.document !== 'undefined' && + // @ts-ignore + typeof navigator !== 'undefined' + ); +}; + +type DetectedPlatform = 'deno' | 'node' | 'edge' | 'unknown'; + +/** + * Note this does not detect 'browser'; for that, use getBrowserInfo(). + */ +function getDetectedPlatform(): DetectedPlatform { + if (typeof Deno !== 'undefined' && Deno.build != null) { + return 'deno'; + } + if (typeof EdgeRuntime !== 'undefined') { + return 'edge'; + } + if ( + Object.prototype.toString.call( + typeof (globalThis as any).process !== 'undefined' ? (globalThis as any).process : 0, + ) === '[object process]' + ) { + return 'node'; + } + return 'unknown'; +} + +declare const Deno: any; +declare const EdgeRuntime: any; +type Arch = 'x32' | 'x64' | 'arm' | 'arm64' | `other:${string}` | 'unknown'; +type PlatformName = + | 'MacOS' + | 'Linux' + | 'Windows' + | 'FreeBSD' + | 'OpenBSD' + | 'iOS' + | 'Android' + | `Other:${string}` + | 'Unknown'; +type Browser = 'ie' | 'edge' | 'chrome' | 'firefox' | 'safari'; +type PlatformProperties = { + 'X-Stainless-Lang': 'js'; + 'X-Stainless-Package-Version': string; + 'X-Stainless-OS': PlatformName; + 'X-Stainless-Arch': Arch; + 'X-Stainless-Runtime': 'node' | 'deno' | 'edge' | `browser:${Browser}` | 'unknown'; + 'X-Stainless-Runtime-Version': string; +}; +const getPlatformProperties = (): PlatformProperties => { + const detectedPlatform = getDetectedPlatform(); + if (detectedPlatform === 'deno') { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': normalizePlatform(Deno.build.os), + 'X-Stainless-Arch': normalizeArch(Deno.build.arch), + 'X-Stainless-Runtime': 'deno', + 'X-Stainless-Runtime-Version': + typeof Deno.version === 'string' ? Deno.version : Deno.version?.deno ?? 'unknown', + }; + } + if (typeof EdgeRuntime !== 'undefined') { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': 'Unknown', + 'X-Stainless-Arch': `other:${EdgeRuntime}`, + 'X-Stainless-Runtime': 'edge', + 'X-Stainless-Runtime-Version': (globalThis as any).process.version, + }; + } + // Check if Node.js + if (detectedPlatform === 'node') { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': normalizePlatform((globalThis as any).process.platform ?? 'unknown'), + 'X-Stainless-Arch': normalizeArch((globalThis as any).process.arch ?? 'unknown'), + 'X-Stainless-Runtime': 'node', + 'X-Stainless-Runtime-Version': (globalThis as any).process.version ?? 'unknown', + }; + } + + const browserInfo = getBrowserInfo(); + if (browserInfo) { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': 'Unknown', + 'X-Stainless-Arch': 'unknown', + 'X-Stainless-Runtime': `browser:${browserInfo.browser}`, + 'X-Stainless-Runtime-Version': browserInfo.version, + }; + } + + // TODO add support for Cloudflare workers, etc. + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': 'Unknown', + 'X-Stainless-Arch': 'unknown', + 'X-Stainless-Runtime': 'unknown', + 'X-Stainless-Runtime-Version': 'unknown', + }; +}; + +type BrowserInfo = { + browser: Browser; + version: string; +}; + +declare const navigator: { userAgent: string } | undefined; + +// Note: modified from https://github.com/JS-DevTools/host-environment/blob/b1ab79ecde37db5d6e163c050e54fe7d287d7c92/src/isomorphic.browser.ts +function getBrowserInfo(): BrowserInfo | null { + if (typeof navigator === 'undefined' || !navigator) { + return null; + } + + // NOTE: The order matters here! + const browserPatterns = [ + { key: 'edge' as const, pattern: /Edge(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'ie' as const, pattern: /MSIE(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'ie' as const, pattern: /Trident(?:.*rv\:(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'chrome' as const, pattern: /Chrome(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'firefox' as const, pattern: /Firefox(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'safari' as const, pattern: /(?:Version\W+(\d+)\.(\d+)(?:\.(\d+))?)?(?:\W+Mobile\S*)?\W+Safari/ }, + ]; + + // Find the FIRST matching browser + for (const { key, pattern } of browserPatterns) { + const match = pattern.exec(navigator.userAgent); + if (match) { + const major = match[1] || 0; + const minor = match[2] || 0; + const patch = match[3] || 0; + + return { browser: key, version: `${major}.${minor}.${patch}` }; + } + } + + return null; +} + +const normalizeArch = (arch: string): Arch => { + // Node docs: + // - https://nodejs.org/api/process.html#processarch + // Deno docs: + // - https://doc.deno.land/deno/stable/~/Deno.build + if (arch === 'x32') return 'x32'; + if (arch === 'x86_64' || arch === 'x64') return 'x64'; + if (arch === 'arm') return 'arm'; + if (arch === 'aarch64' || arch === 'arm64') return 'arm64'; + if (arch) return `other:${arch}`; + return 'unknown'; +}; + +const normalizePlatform = (platform: string): PlatformName => { + // Node platforms: + // - https://nodejs.org/api/process.html#processplatform + // Deno platforms: + // - https://doc.deno.land/deno/stable/~/Deno.build + // - https://github.com/denoland/deno/issues/14799 + + platform = platform.toLowerCase(); + + // NOTE: this iOS check is untested and may not work + // Node does not work natively on IOS, there is a fork at + // https://github.com/nodejs-mobile/nodejs-mobile + // however it is unknown at the time of writing how to detect if it is running + if (platform.includes('ios')) return 'iOS'; + if (platform === 'android') return 'Android'; + if (platform === 'darwin') return 'MacOS'; + if (platform === 'win32') return 'Windows'; + if (platform === 'freebsd') return 'FreeBSD'; + if (platform === 'openbsd') return 'OpenBSD'; + if (platform === 'linux') return 'Linux'; + if (platform) return `Other:${platform}`; + return 'Unknown'; +}; + +let _platformHeaders: PlatformProperties; +export const getPlatformHeaders = () => { + return (_platformHeaders ??= getPlatformProperties()); +}; diff --git a/packages/sdk/src/internal/errors.ts b/packages/sdk/src/internal/errors.ts new file mode 100644 index 000000000..82c7b14d5 --- /dev/null +++ b/packages/sdk/src/internal/errors.ts @@ -0,0 +1,33 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export function isAbortError(err: unknown) { + return ( + typeof err === 'object' && + err !== null && + // Spec-compliant fetch implementations + (('name' in err && (err as any).name === 'AbortError') || + // Expo fetch + ('message' in err && String((err as any).message).includes('FetchRequestCanceledException'))) + ); +} + +export const castToError = (err: any): Error => { + if (err instanceof Error) return err; + if (typeof err === 'object' && err !== null) { + try { + if (Object.prototype.toString.call(err) === '[object Error]') { + // @ts-ignore - not all envs have native support for cause yet + const error = new Error(err.message, err.cause ? { cause: err.cause } : {}); + if (err.stack) error.stack = err.stack; + // @ts-ignore - not all envs have native support for cause yet + if (err.cause && !error.cause) error.cause = err.cause; + if (err.name) error.name = err.name; + return error; + } + } catch {} + try { + return new Error(JSON.stringify(err)); + } catch {} + } + return new Error(err); +}; diff --git a/packages/sdk/src/internal/headers.ts b/packages/sdk/src/internal/headers.ts new file mode 100644 index 000000000..c724a9d22 --- /dev/null +++ b/packages/sdk/src/internal/headers.ts @@ -0,0 +1,97 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { isReadonlyArray } from './utils/values'; + +type HeaderValue = string | undefined | null; +export type HeadersLike = + | Headers + | readonly HeaderValue[][] + | Record<string, HeaderValue | readonly HeaderValue[]> + | undefined + | null + | NullableHeaders; + +const brand_privateNullableHeaders = /* @__PURE__ */ Symbol('brand.privateNullableHeaders'); + +/** + * @internal + * Users can pass explicit nulls to unset default headers. When we parse them + * into a standard headers type we need to preserve that information. + */ +export type NullableHeaders = { + /** Brand check, prevent users from creating a NullableHeaders. */ + [brand_privateNullableHeaders]: true; + /** Parsed headers. */ + values: Headers; + /** Set of lowercase header names explicitly set to null. */ + nulls: Set<string>; +}; + +function* iterateHeaders(headers: HeadersLike): IterableIterator<readonly [string, string | null]> { + if (!headers) return; + + if (brand_privateNullableHeaders in headers) { + const { values, nulls } = headers; + yield* values.entries(); + for (const name of nulls) { + yield [name, null]; + } + return; + } + + let shouldClear = false; + let iter: Iterable<readonly (HeaderValue | readonly HeaderValue[])[]>; + if (headers instanceof Headers) { + iter = headers.entries(); + } else if (isReadonlyArray(headers)) { + iter = headers; + } else { + shouldClear = true; + iter = Object.entries(headers ?? {}); + } + for (let row of iter) { + const name = row[0]; + if (typeof name !== 'string') throw new TypeError('expected header name to be a string'); + const values = isReadonlyArray(row[1]) ? row[1] : [row[1]]; + let didClear = false; + for (const value of values) { + if (value === undefined) continue; + + // Objects keys always overwrite older headers, they never append. + // Yield a null to clear the header before adding the new values. + if (shouldClear && !didClear) { + didClear = true; + yield [name, null]; + } + yield [name, value]; + } + } +} + +export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { + const targetHeaders = new Headers(); + const nullHeaders = new Set<string>(); + for (const headers of newHeaders) { + const seenHeaders = new Set<string>(); + for (const [name, value] of iterateHeaders(headers)) { + const lowerName = name.toLowerCase(); + if (!seenHeaders.has(lowerName)) { + targetHeaders.delete(name); + seenHeaders.add(lowerName); + } + if (value === null) { + targetHeaders.delete(name); + nullHeaders.add(lowerName); + } else { + targetHeaders.append(name, value); + nullHeaders.delete(lowerName); + } + } + } + return { [brand_privateNullableHeaders]: true, values: targetHeaders, nulls: nullHeaders }; +}; + +export const isEmptyHeaders = (headers: HeadersLike) => { + for (const _ of iterateHeaders(headers)) return false; + return true; +}; diff --git a/packages/sdk/src/internal/parse.ts b/packages/sdk/src/internal/parse.ts new file mode 100644 index 000000000..dcb4026e3 --- /dev/null +++ b/packages/sdk/src/internal/parse.ts @@ -0,0 +1,64 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { FinalRequestOptions } from './request-options'; +import { Stream } from '../core/streaming'; +import { type Opencode } from '../client'; +import { formatRequestDetails, loggerFor } from './utils/log'; + +export type APIResponseProps = { + response: Response; + options: FinalRequestOptions; + controller: AbortController; + requestLogID: string; + retryOfRequestLogID: string | undefined; + startTime: number; +}; + +export async function defaultParseResponse<T>(client: Opencode, props: APIResponseProps): Promise<T> { + const { response, requestLogID, retryOfRequestLogID, startTime } = props; + const body = await (async () => { + if (props.options.stream) { + loggerFor(client).debug('response', response.status, response.url, response.headers, response.body); + + // Note: there is an invariant here that isn't represented in the type system + // that if you set `stream: true` the response type must also be `Stream<T>` + + if (props.options.__streamClass) { + return props.options.__streamClass.fromSSEResponse(response, props.controller, client) as any; + } + + return Stream.fromSSEResponse(response, props.controller, client) as any; + } + + // fetch refuses to read the body when the status code is 204. + if (response.status === 204) { + return null as T; + } + + if (props.options.__binaryResponse) { + return response as unknown as T; + } + + const contentType = response.headers.get('content-type'); + const mediaType = contentType?.split(';')[0]?.trim(); + const isJSON = mediaType?.includes('application/json') || mediaType?.endsWith('+json'); + if (isJSON) { + const json = await response.json(); + return json as T; + } + + const text = await response.text(); + return text as unknown as T; + })(); + loggerFor(client).debug( + `[${requestLogID}] response parsed`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + body, + durationMs: Date.now() - startTime, + }), + ); + return body; +} diff --git a/packages/sdk/src/internal/request-options.ts b/packages/sdk/src/internal/request-options.ts new file mode 100644 index 000000000..56765e5aa --- /dev/null +++ b/packages/sdk/src/internal/request-options.ts @@ -0,0 +1,93 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { NullableHeaders } from './headers'; + +import type { BodyInit } from './builtin-types'; +import { Stream } from '../core/streaming'; +import type { HTTPMethod, MergedRequestInit } from './types'; +import { type HeadersLike } from './headers'; + +export type FinalRequestOptions = RequestOptions & { method: HTTPMethod; path: string }; + +export type RequestOptions = { + /** + * The HTTP method for the request (e.g., 'get', 'post', 'put', 'delete'). + */ + method?: HTTPMethod; + + /** + * The URL path for the request. + * + * @example "/v1/foo" + */ + path?: string; + + /** + * Query parameters to include in the request URL. + */ + query?: object | undefined | null; + + /** + * The request body. Can be a string, JSON object, FormData, or other supported types. + */ + body?: unknown; + + /** + * HTTP headers to include with the request. Can be a Headers object, plain object, or array of tuples. + */ + headers?: HeadersLike; + + /** + * The maximum number of times that the client will retry a request in case of a + * temporary failure, like a network error or a 5XX error from the server. + * + * @default 2 + */ + maxRetries?: number; + + stream?: boolean | undefined; + + /** + * The maximum amount of time (in milliseconds) that the client should wait for a response + * from the server before timing out a single request. + * + * @unit milliseconds + */ + timeout?: number; + + /** + * Additional `RequestInit` options to be passed to the underlying `fetch` call. + * These options will be merged with the client's default fetch options. + */ + fetchOptions?: MergedRequestInit; + + /** + * An AbortSignal that can be used to cancel the request. + */ + signal?: AbortSignal | undefined | null; + + /** + * A unique key for this request to enable idempotency. + */ + idempotencyKey?: string; + + /** + * Override the default base URL for this specific request. + */ + defaultBaseURL?: string | undefined; + + __binaryResponse?: boolean | undefined; + __streamClass?: typeof Stream; +}; + +export type EncodedContent = { bodyHeaders: HeadersLike; body: BodyInit }; +export type RequestEncoder = (request: { headers: NullableHeaders; body: unknown }) => EncodedContent; + +export const FallbackEncoder: RequestEncoder = ({ headers, body }) => { + return { + bodyHeaders: { + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }; +}; diff --git a/packages/sdk/src/internal/shim-types.ts b/packages/sdk/src/internal/shim-types.ts new file mode 100644 index 000000000..8ddf7b0ad --- /dev/null +++ b/packages/sdk/src/internal/shim-types.ts @@ -0,0 +1,26 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * Shims for types that we can't always rely on being available globally. + * + * Note: these only exist at the type-level, there is no corresponding runtime + * version for any of these symbols. + */ + +type NeverToAny<T> = T extends never ? any : T; + +/** @ts-ignore */ +type _DOMReadableStream<R = any> = globalThis.ReadableStream<R>; + +/** @ts-ignore */ +type _NodeReadableStream<R = any> = import('stream/web').ReadableStream<R>; + +type _ConditionalNodeReadableStream<R = any> = + typeof globalThis extends { ReadableStream: any } ? never : _NodeReadableStream<R>; + +type _ReadableStream<R = any> = NeverToAny< + | ([0] extends [1 & _DOMReadableStream<R>] ? never : _DOMReadableStream<R>) + | ([0] extends [1 & _ConditionalNodeReadableStream<R>] ? never : _ConditionalNodeReadableStream<R>) +>; + +export type { _ReadableStream as ReadableStream }; diff --git a/packages/sdk/src/internal/shims.ts b/packages/sdk/src/internal/shims.ts new file mode 100644 index 000000000..17a7967aa --- /dev/null +++ b/packages/sdk/src/internal/shims.ts @@ -0,0 +1,107 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * This module provides internal shims and utility functions for environments where certain Node.js or global types may not be available. + * + * These are used to ensure we can provide a consistent behaviour between different JavaScript environments and good error + * messages in cases where an environment isn't fully supported. + */ + +import type { Fetch } from './builtin-types'; +import type { ReadableStream } from './shim-types'; + +export function getDefaultFetch(): Fetch { + if (typeof fetch !== 'undefined') { + return fetch as any; + } + + throw new Error( + '`fetch` is not defined as a global; Either pass `fetch` to the client, `new Opencode({ fetch })` or polyfill the global, `globalThis.fetch = fetch`', + ); +} + +type ReadableStreamArgs = ConstructorParameters<typeof ReadableStream>; + +export function makeReadableStream(...args: ReadableStreamArgs): ReadableStream { + const ReadableStream = (globalThis as any).ReadableStream; + if (typeof ReadableStream === 'undefined') { + // Note: All of the platforms / runtimes we officially support already define + // `ReadableStream` as a global, so this should only ever be hit on unsupported runtimes. + throw new Error( + '`ReadableStream` is not defined as a global; You will need to polyfill it, `globalThis.ReadableStream = ReadableStream`', + ); + } + + return new ReadableStream(...args); +} + +export function ReadableStreamFrom<T>(iterable: Iterable<T> | AsyncIterable<T>): ReadableStream<T> { + let iter: AsyncIterator<T> | Iterator<T> = + Symbol.asyncIterator in iterable ? iterable[Symbol.asyncIterator]() : iterable[Symbol.iterator](); + + return makeReadableStream({ + start() {}, + async pull(controller: any) { + const { done, value } = await iter.next(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + }, + async cancel() { + await iter.return?.(); + }, + }); +} + +/** + * Most browsers don't yet have async iterable support for ReadableStream, + * and Node has a very different way of reading bytes from its "ReadableStream". + * + * This polyfill was pulled from https://github.com/MattiasBuelens/web-streams-polyfill/pull/122#issuecomment-1627354490 + */ +export function ReadableStreamToAsyncIterable<T>(stream: any): AsyncIterableIterator<T> { + if (stream[Symbol.asyncIterator]) return stream; + + const reader = stream.getReader(); + return { + async next() { + try { + const result = await reader.read(); + if (result?.done) reader.releaseLock(); // release lock when stream becomes closed + return result; + } catch (e) { + reader.releaseLock(); // release lock when stream becomes errored + throw e; + } + }, + async return() { + const cancelPromise = reader.cancel(); + reader.releaseLock(); + await cancelPromise; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +/** + * Cancels a ReadableStream we don't need to consume. + * See https://undici.nodejs.org/#/?id=garbage-collection + */ +export async function CancelReadableStream(stream: any): Promise<void> { + if (stream === null || typeof stream !== 'object') return; + + if (stream[Symbol.asyncIterator]) { + await stream[Symbol.asyncIterator]().return?.(); + return; + } + + const reader = stream.getReader(); + const cancelPromise = reader.cancel(); + reader.releaseLock(); + await cancelPromise; +} diff --git a/packages/sdk/src/internal/to-file.ts b/packages/sdk/src/internal/to-file.ts new file mode 100644 index 000000000..245e84933 --- /dev/null +++ b/packages/sdk/src/internal/to-file.ts @@ -0,0 +1,154 @@ +import { BlobPart, getName, makeFile, isAsyncIterable } from './uploads'; +import type { FilePropertyBag } from './builtin-types'; +import { checkFileSupport } from './uploads'; + +type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | DataView; + +/** + * Intended to match DOM Blob, node-fetch Blob, node:buffer Blob, etc. + * Don't add arrayBuffer here, node-fetch doesn't have it + */ +interface BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */ + readonly size: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */ + readonly type: string; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ + text(): Promise<string>; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */ + slice(start?: number, end?: number): BlobLike; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isBlobLike = (value: any): value is BlobLike & { arrayBuffer(): Promise<ArrayBuffer> } => + value != null && + typeof value === 'object' && + typeof value.size === 'number' && + typeof value.type === 'string' && + typeof value.text === 'function' && + typeof value.slice === 'function' && + typeof value.arrayBuffer === 'function'; + +/** + * Intended to match DOM File, node:buffer File, undici File, etc. + */ +interface FileLike extends BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */ + readonly lastModified: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */ + readonly name?: string | undefined; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isFileLike = (value: any): value is FileLike & { arrayBuffer(): Promise<ArrayBuffer> } => + value != null && + typeof value === 'object' && + typeof value.name === 'string' && + typeof value.lastModified === 'number' && + isBlobLike(value); + +/** + * Intended to match DOM Response, node-fetch Response, undici Response, etc. + */ +export interface ResponseLike { + url: string; + blob(): Promise<BlobLike>; +} + +const isResponseLike = (value: any): value is ResponseLike => + value != null && + typeof value === 'object' && + typeof value.url === 'string' && + typeof value.blob === 'function'; + +export type ToFileInput = + | FileLike + | ResponseLike + | Exclude<BlobLikePart, string> + | AsyncIterable<BlobLikePart>; + +/** + * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats + * @param value the raw content of the file. Can be an {@link Uploadable}, {@link BlobLikePart}, or {@link AsyncIterable} of {@link BlobLikePart}s + * @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible + * @param {Object=} options additional properties + * @param {string=} options.type the MIME type of the content + * @param {number=} options.lastModified the last modified timestamp + * @returns a {@link File} with the given properties + */ +export async function toFile( + value: ToFileInput | PromiseLike<ToFileInput>, + name?: string | null | undefined, + options?: FilePropertyBag | undefined, +): Promise<File> { + checkFileSupport(); + + // If it's a promise, resolve it. + value = await value; + + // If we've been given a `File` we don't need to do anything + if (isFileLike(value)) { + if (value instanceof File) { + return value; + } + return makeFile([await value.arrayBuffer()], value.name); + } + + if (isResponseLike(value)) { + const blob = await value.blob(); + name ||= new URL(value.url).pathname.split(/[\\/]/).pop(); + + return makeFile(await getBytes(blob), name, options); + } + + const parts = await getBytes(value); + + name ||= getName(value); + + if (!options?.type) { + const type = parts.find((part) => typeof part === 'object' && 'type' in part && part.type); + if (typeof type === 'string') { + options = { ...options, type }; + } + } + + return makeFile(parts, name, options); +} + +async function getBytes(value: BlobLikePart | AsyncIterable<BlobLikePart>): Promise<Array<BlobPart>> { + let parts: Array<BlobPart> = []; + if ( + typeof value === 'string' || + ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc. + value instanceof ArrayBuffer + ) { + parts.push(value); + } else if (isBlobLike(value)) { + parts.push(value instanceof Blob ? value : await value.arrayBuffer()); + } else if ( + isAsyncIterable(value) // includes Readable, ReadableStream, etc. + ) { + for await (const chunk of value) { + parts.push(...(await getBytes(chunk as BlobLikePart))); // TODO, consider validating? + } + } else { + const constructor = value?.constructor?.name; + throw new Error( + `Unexpected data type: ${typeof value}${ + constructor ? `; constructor: ${constructor}` : '' + }${propsForError(value)}`, + ); + } + + return parts; +} + +function propsForError(value: unknown): string { + if (typeof value !== 'object' || value === null) return ''; + const props = Object.getOwnPropertyNames(value); + return `; props: [${props.map((p) => `"${p}"`).join(', ')}]`; +} diff --git a/packages/sdk/src/internal/types.ts b/packages/sdk/src/internal/types.ts new file mode 100644 index 000000000..b668dfc0f --- /dev/null +++ b/packages/sdk/src/internal/types.ts @@ -0,0 +1,95 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export type PromiseOrValue<T> = T | Promise<T>; +export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; + +export type KeysEnum<T> = { [P in keyof Required<T>]: true }; + +export type FinalizedRequestInit = RequestInit & { headers: Headers }; + +type NotAny<T> = [0] extends [1 & T] ? never : T; + +/** + * Some environments overload the global fetch function, and Parameters<T> only gets the last signature. + */ +type OverloadedParameters<T> = + T extends ( + { + (...args: infer A): unknown; + (...args: infer B): unknown; + (...args: infer C): unknown; + (...args: infer D): unknown; + } + ) ? + A | B | C | D + : T extends ( + { + (...args: infer A): unknown; + (...args: infer B): unknown; + (...args: infer C): unknown; + } + ) ? + A | B | C + : T extends ( + { + (...args: infer A): unknown; + (...args: infer B): unknown; + } + ) ? + A | B + : T extends (...args: infer A) => unknown ? A + : never; + +/* eslint-disable */ +/** + * These imports attempt to get types from a parent package's dependencies. + * Unresolved bare specifiers can trigger [automatic type acquisition][1] in some projects, which + * would cause typescript to show types not present at runtime. To avoid this, we import + * directly from parent node_modules folders. + * + * We need to check multiple levels because we don't know what directory structure we'll be in. + * For example, pnpm generates directories like this: + * ``` + * node_modules + * ├── .pnpm + * │ └── [email protected] + * │ └── node_modules + * │ └── pkg + * │ └── internal + * │ └── types.d.ts + * ├── pkg -> .pnpm/[email protected]/node_modules/pkg + * └── undici + * ``` + * + * [1]: https://www.typescriptlang.org/tsconfig/#typeAcquisition + */ +/** @ts-ignore For users with \@types/node */ +type UndiciTypesRequestInit = NotAny<import('../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../node_modules/undici-types/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../../node_modules/undici-types/index.d.ts').RequestInit>; +/** @ts-ignore For users with undici */ +type UndiciRequestInit = NotAny<import('../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../node_modules/undici/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../../node_modules/undici/index.d.ts').RequestInit>; +/** @ts-ignore For users with \@types/bun */ +type BunRequestInit = globalThis.FetchRequestInit; +/** @ts-ignore For users with node-fetch@2 */ +type NodeFetch2RequestInit = NotAny<import('../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit> | NotAny<import('../../../../../../../../../../node_modules/@types/node-fetch/index.d.ts').RequestInit>; +/** @ts-ignore For users with node-fetch@3, doesn't need file extension because types are at ./@types/index.d.ts */ +type NodeFetch3RequestInit = NotAny<import('../node_modules/node-fetch').RequestInit> | NotAny<import('../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../../../node_modules/node-fetch').RequestInit> | NotAny<import('../../../../../../../../../../node_modules/node-fetch').RequestInit>; +/** @ts-ignore For users who use Deno */ +type FetchRequestInit = NonNullable<OverloadedParameters<typeof fetch>[1]>; +/* eslint-enable */ + +type RequestInits = + | NotAny<UndiciTypesRequestInit> + | NotAny<UndiciRequestInit> + | NotAny<BunRequestInit> + | NotAny<NodeFetch2RequestInit> + | NotAny<NodeFetch3RequestInit> + | NotAny<RequestInit> + | NotAny<FetchRequestInit>; + +/** + * This type contains `RequestInit` options that may be available on the current runtime, + * including per-platform extensions like `dispatcher`, `agent`, `client`, etc. + */ +export type MergedRequestInit = RequestInits & + /** We don't include these in the types as they'll be overridden for every request. */ + Partial<Record<'body' | 'headers' | 'method' | 'signal', never>>; diff --git a/packages/sdk/src/internal/uploads.ts b/packages/sdk/src/internal/uploads.ts new file mode 100644 index 000000000..eb55f834e --- /dev/null +++ b/packages/sdk/src/internal/uploads.ts @@ -0,0 +1,187 @@ +import { type RequestOptions } from './request-options'; +import type { FilePropertyBag, Fetch } from './builtin-types'; +import type { Opencode } from '../client'; +import { ReadableStreamFrom } from './shims'; + +export type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | DataView; +type FsReadStream = AsyncIterable<Uint8Array> & { path: string | { toString(): string } }; + +// https://github.com/oven-sh/bun/issues/5980 +interface BunFile extends Blob { + readonly name?: string | undefined; +} + +export const checkFileSupport = () => { + if (typeof File === 'undefined') { + const { process } = globalThis as any; + const isOldNode = + typeof process?.versions?.node === 'string' && parseInt(process.versions.node.split('.')) < 20; + throw new Error( + '`File` is not defined as a global, which is required for file uploads.' + + (isOldNode ? + " Update to Node 20 LTS or newer, or set `globalThis.File` to `import('node:buffer').File`." + : ''), + ); + } +}; + +/** + * Typically, this is a native "File" class. + * + * We provide the {@link toFile} utility to convert a variety of objects + * into the File class. + * + * For convenience, you can also pass a fetch Response, or in Node, + * the result of fs.createReadStream(). + */ +export type Uploadable = File | Response | FsReadStream | BunFile; + +/** + * Construct a `File` instance. This is used to ensure a helpful error is thrown + * for environments that don't define a global `File` yet. + */ +export function makeFile( + fileBits: BlobPart[], + fileName: string | undefined, + options?: FilePropertyBag, +): File { + checkFileSupport(); + return new File(fileBits as any, fileName ?? 'unknown_file', options); +} + +export function getName(value: any): string | undefined { + return ( + ( + (typeof value === 'object' && + value !== null && + (('name' in value && value.name && String(value.name)) || + ('url' in value && value.url && String(value.url)) || + ('filename' in value && value.filename && String(value.filename)) || + ('path' in value && value.path && String(value.path)))) || + '' + ) + .split(/[\\/]/) + .pop() || undefined + ); +} + +export const isAsyncIterable = (value: any): value is AsyncIterable<any> => + value != null && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function'; + +/** + * Returns a multipart/form-data request if any part of the given request body contains a File / Blob value. + * Otherwise returns the request as is. + */ +export const maybeMultipartFormRequestOptions = async ( + opts: RequestOptions, + fetch: Opencode | Fetch, +): Promise<RequestOptions> => { + if (!hasUploadableValue(opts.body)) return opts; + + return { ...opts, body: await createForm(opts.body, fetch) }; +}; + +type MultipartFormRequestOptions = Omit<RequestOptions, 'body'> & { body: unknown }; + +export const multipartFormRequestOptions = async ( + opts: MultipartFormRequestOptions, + fetch: Opencode | Fetch, +): Promise<RequestOptions> => { + return { ...opts, body: await createForm(opts.body, fetch) }; +}; + +const supportsFormDataMap = /* @__PURE__ */ new WeakMap<Fetch, Promise<boolean>>(); + +/** + * node-fetch doesn't support the global FormData object in recent node versions. Instead of sending + * properly-encoded form data, it just stringifies the object, resulting in a request body of "[object FormData]". + * This function detects if the fetch function provided supports the global FormData object to avoid + * confusing error messages later on. + */ +function supportsFormData(fetchObject: Opencode | Fetch): Promise<boolean> { + const fetch: Fetch = typeof fetchObject === 'function' ? fetchObject : (fetchObject as any).fetch; + const cached = supportsFormDataMap.get(fetch); + if (cached) return cached; + const promise = (async () => { + try { + const FetchResponse = ( + 'Response' in fetch ? + fetch.Response + : (await fetch('data:,')).constructor) as typeof Response; + const data = new FormData(); + if (data.toString() === (await new FetchResponse(data).text())) { + return false; + } + return true; + } catch { + // avoid false negatives + return true; + } + })(); + supportsFormDataMap.set(fetch, promise); + return promise; +} + +export const createForm = async <T = Record<string, unknown>>( + body: T | undefined, + fetch: Opencode | Fetch, +): Promise<FormData> => { + if (!(await supportsFormData(fetch))) { + throw new TypeError( + 'The provided fetch function does not support file uploads with the current global FormData class.', + ); + } + const form = new FormData(); + await Promise.all(Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value))); + return form; +}; + +// We check for Blob not File because Bun.File doesn't inherit from File, +// but they both inherit from Blob and have a `name` property at runtime. +const isNamedBlob = (value: unknown) => value instanceof Blob && 'name' in value; + +const isUploadable = (value: unknown) => + typeof value === 'object' && + value !== null && + (value instanceof Response || isAsyncIterable(value) || isNamedBlob(value)); + +const hasUploadableValue = (value: unknown): boolean => { + if (isUploadable(value)) return true; + if (Array.isArray(value)) return value.some(hasUploadableValue); + if (value && typeof value === 'object') { + for (const k in value) { + if (hasUploadableValue((value as any)[k])) return true; + } + } + return false; +}; + +const addFormValue = async (form: FormData, key: string, value: unknown): Promise<void> => { + if (value === undefined) return; + if (value == null) { + throw new TypeError( + `Received null for "${key}"; to pass null in FormData, you must use the string 'null'`, + ); + } + + // TODO: make nested formats configurable + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + form.append(key, String(value)); + } else if (value instanceof Response) { + form.append(key, makeFile([await value.blob()], getName(value))); + } else if (isAsyncIterable(value)) { + form.append(key, makeFile([await new Response(ReadableStreamFrom(value)).blob()], getName(value))); + } else if (isNamedBlob(value)) { + form.append(key, value, getName(value)); + } else if (Array.isArray(value)) { + await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry))); + } else if (typeof value === 'object') { + await Promise.all( + Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)), + ); + } else { + throw new TypeError( + `Invalid value given to form, expected a string, number, boolean, object, Array, File or Blob but got ${value} instead`, + ); + } +}; diff --git a/packages/sdk/src/internal/utils.ts b/packages/sdk/src/internal/utils.ts new file mode 100644 index 000000000..3cbfacce2 --- /dev/null +++ b/packages/sdk/src/internal/utils.ts @@ -0,0 +1,8 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from './utils/values'; +export * from './utils/base64'; +export * from './utils/env'; +export * from './utils/log'; +export * from './utils/uuid'; +export * from './utils/sleep'; diff --git a/packages/sdk/src/internal/utils/base64.ts b/packages/sdk/src/internal/utils/base64.ts new file mode 100644 index 000000000..05d7bd9b4 --- /dev/null +++ b/packages/sdk/src/internal/utils/base64.ts @@ -0,0 +1,40 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { OpencodeError } from '../../core/error'; +import { encodeUTF8 } from './bytes'; + +export const toBase64 = (data: string | Uint8Array | null | undefined): string => { + if (!data) return ''; + + if (typeof (globalThis as any).Buffer !== 'undefined') { + return (globalThis as any).Buffer.from(data).toString('base64'); + } + + if (typeof data === 'string') { + data = encodeUTF8(data); + } + + if (typeof btoa !== 'undefined') { + return btoa(String.fromCharCode.apply(null, data as any)); + } + + throw new OpencodeError('Cannot generate base64 string; Expected `Buffer` or `btoa` to be defined'); +}; + +export const fromBase64 = (str: string): Uint8Array => { + if (typeof (globalThis as any).Buffer !== 'undefined') { + const buf = (globalThis as any).Buffer.from(str, 'base64'); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + + if (typeof atob !== 'undefined') { + const bstr = atob(str); + const buf = new Uint8Array(bstr.length); + for (let i = 0; i < bstr.length; i++) { + buf[i] = bstr.charCodeAt(i); + } + return buf; + } + + throw new OpencodeError('Cannot decode base64 string; Expected `Buffer` or `atob` to be defined'); +}; diff --git a/packages/sdk/src/internal/utils/bytes.ts b/packages/sdk/src/internal/utils/bytes.ts new file mode 100644 index 000000000..8da627abe --- /dev/null +++ b/packages/sdk/src/internal/utils/bytes.ts @@ -0,0 +1,32 @@ +export function concatBytes(buffers: Uint8Array[]): Uint8Array { + let length = 0; + for (const buffer of buffers) { + length += buffer.length; + } + const output = new Uint8Array(length); + let index = 0; + for (const buffer of buffers) { + output.set(buffer, index); + index += buffer.length; + } + + return output; +} + +let encodeUTF8_: (str: string) => Uint8Array; +export function encodeUTF8(str: string) { + let encoder; + return ( + encodeUTF8_ ?? + ((encoder = new (globalThis as any).TextEncoder()), (encodeUTF8_ = encoder.encode.bind(encoder))) + )(str); +} + +let decodeUTF8_: (bytes: Uint8Array) => string; +export function decodeUTF8(bytes: Uint8Array) { + let decoder; + return ( + decodeUTF8_ ?? + ((decoder = new (globalThis as any).TextDecoder()), (decodeUTF8_ = decoder.decode.bind(decoder))) + )(bytes); +} diff --git a/packages/sdk/src/internal/utils/env.ts b/packages/sdk/src/internal/utils/env.ts new file mode 100644 index 000000000..2d8480077 --- /dev/null +++ b/packages/sdk/src/internal/utils/env.ts @@ -0,0 +1,18 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * Read an environment variable. + * + * Trims beginning and trailing whitespace. + * + * Will return undefined if the environment variable doesn't exist or cannot be accessed. + */ +export const readEnv = (env: string): string | undefined => { + if (typeof (globalThis as any).process !== 'undefined') { + return (globalThis as any).process.env?.[env]?.trim() ?? undefined; + } + if (typeof (globalThis as any).Deno !== 'undefined') { + return (globalThis as any).Deno.env?.get?.(env)?.trim(); + } + return undefined; +}; diff --git a/packages/sdk/src/internal/utils/log.ts b/packages/sdk/src/internal/utils/log.ts new file mode 100644 index 000000000..44ac16a02 --- /dev/null +++ b/packages/sdk/src/internal/utils/log.ts @@ -0,0 +1,126 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { hasOwn } from './values'; +import { type Opencode } from '../../client'; +import { RequestOptions } from '../request-options'; + +type LogFn = (message: string, ...rest: unknown[]) => void; +export type Logger = { + error: LogFn; + warn: LogFn; + info: LogFn; + debug: LogFn; +}; +export type LogLevel = 'off' | 'error' | 'warn' | 'info' | 'debug'; + +const levelNumbers = { + off: 0, + error: 200, + warn: 300, + info: 400, + debug: 500, +}; + +export const parseLogLevel = ( + maybeLevel: string | undefined, + sourceName: string, + client: Opencode, +): LogLevel | undefined => { + if (!maybeLevel) { + return undefined; + } + if (hasOwn(levelNumbers, maybeLevel)) { + return maybeLevel; + } + loggerFor(client).warn( + `${sourceName} was set to ${JSON.stringify(maybeLevel)}, expected one of ${JSON.stringify( + Object.keys(levelNumbers), + )}`, + ); + return undefined; +}; + +function noop() {} + +function makeLogFn(fnLevel: keyof Logger, logger: Logger | undefined, logLevel: LogLevel) { + if (!logger || levelNumbers[fnLevel] > levelNumbers[logLevel]) { + return noop; + } else { + // Don't wrap logger functions, we want the stacktrace intact! + return logger[fnLevel].bind(logger); + } +} + +const noopLogger = { + error: noop, + warn: noop, + info: noop, + debug: noop, +}; + +let cachedLoggers = /* @__PURE__ */ new WeakMap<Logger, [LogLevel, Logger]>(); + +export function loggerFor(client: Opencode): Logger { + const logger = client.logger; + const logLevel = client.logLevel ?? 'off'; + if (!logger) { + return noopLogger; + } + + const cachedLogger = cachedLoggers.get(logger); + if (cachedLogger && cachedLogger[0] === logLevel) { + return cachedLogger[1]; + } + + const levelLogger = { + error: makeLogFn('error', logger, logLevel), + warn: makeLogFn('warn', logger, logLevel), + info: makeLogFn('info', logger, logLevel), + debug: makeLogFn('debug', logger, logLevel), + }; + + cachedLoggers.set(logger, [logLevel, levelLogger]); + + return levelLogger; +} + +export const formatRequestDetails = (details: { + options?: RequestOptions | undefined; + headers?: Headers | Record<string, string> | undefined; + retryOfRequestLogID?: string | undefined; + retryOf?: string | undefined; + url?: string | undefined; + status?: number | undefined; + method?: string | undefined; + durationMs?: number | undefined; + message?: unknown; + body?: unknown; +}) => { + if (details.options) { + details.options = { ...details.options }; + delete details.options['headers']; // redundant + leaks internals + } + if (details.headers) { + details.headers = Object.fromEntries( + (details.headers instanceof Headers ? [...details.headers] : Object.entries(details.headers)).map( + ([name, value]) => [ + name, + ( + name.toLowerCase() === 'authorization' || + name.toLowerCase() === 'cookie' || + name.toLowerCase() === 'set-cookie' + ) ? + '***' + : value, + ], + ), + ); + } + if ('retryOfRequestLogID' in details) { + if (details.retryOfRequestLogID) { + details.retryOf = details.retryOfRequestLogID; + } + delete details.retryOfRequestLogID; + } + return details; +}; diff --git a/packages/sdk/src/internal/utils/path.ts b/packages/sdk/src/internal/utils/path.ts new file mode 100644 index 000000000..6ca96e0dd --- /dev/null +++ b/packages/sdk/src/internal/utils/path.ts @@ -0,0 +1,88 @@ +import { OpencodeError } from '../../core/error'; + +/** + * Percent-encode everything that isn't safe to have in a path without encoding safe chars. + * + * Taken from https://datatracker.ietf.org/doc/html/rfc3986#section-3.3: + * > unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * > sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" + * > pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + */ +export function encodeURIPath(str: string) { + return str.replace(/[^A-Za-z0-9\-._~!$&'()*+,;=:@]+/g, encodeURIComponent); +} + +const EMPTY = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.create(null)); + +export const createPathTagFunction = (pathEncoder = encodeURIPath) => + function path(statics: readonly string[], ...params: readonly unknown[]): string { + // If there are no params, no processing is needed. + if (statics.length === 1) return statics[0]!; + + let postPath = false; + const invalidSegments = []; + const path = statics.reduce((previousValue, currentValue, index) => { + if (/[?#]/.test(currentValue)) { + postPath = true; + } + const value = params[index]; + let encoded = (postPath ? encodeURIComponent : pathEncoder)('' + value); + if ( + index !== params.length && + (value == null || + (typeof value === 'object' && + // handle values from other realms + value.toString === + Object.getPrototypeOf(Object.getPrototypeOf((value as any).hasOwnProperty ?? EMPTY) ?? EMPTY) + ?.toString)) + ) { + encoded = value + ''; + invalidSegments.push({ + start: previousValue.length + currentValue.length, + length: encoded.length, + error: `Value of type ${Object.prototype.toString + .call(value) + .slice(8, -1)} is not a valid path parameter`, + }); + } + return previousValue + currentValue + (index === params.length ? '' : encoded); + }, ''); + + const pathOnly = path.split(/[?#]/, 1)[0]!; + const invalidSegmentPattern = /(?<=^|\/)(?:\.|%2e){1,2}(?=\/|$)/gi; + let match; + + // Find all invalid segments + while ((match = invalidSegmentPattern.exec(pathOnly)) !== null) { + invalidSegments.push({ + start: match.index, + length: match[0].length, + error: `Value "${match[0]}" can\'t be safely passed as a path parameter`, + }); + } + + invalidSegments.sort((a, b) => a.start - b.start); + + if (invalidSegments.length > 0) { + let lastEnd = 0; + const underline = invalidSegments.reduce((acc, segment) => { + const spaces = ' '.repeat(segment.start - lastEnd); + const arrows = '^'.repeat(segment.length); + lastEnd = segment.start + segment.length; + return acc + spaces + arrows; + }, ''); + + throw new OpencodeError( + `Path parameters result in path with invalid segments:\n${invalidSegments + .map((e) => e.error) + .join('\n')}\n${path}\n${underline}`, + ); + } + + return path; + }; + +/** + * URI-encodes path params and ensures no unsafe /./ or /../ path segments are introduced. + */ +export const path = /* @__PURE__ */ createPathTagFunction(encodeURIPath); diff --git a/packages/sdk/src/internal/utils/sleep.ts b/packages/sdk/src/internal/utils/sleep.ts new file mode 100644 index 000000000..65e52962b --- /dev/null +++ b/packages/sdk/src/internal/utils/sleep.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); diff --git a/packages/sdk/src/internal/utils/uuid.ts b/packages/sdk/src/internal/utils/uuid.ts new file mode 100644 index 000000000..b0e53aaf7 --- /dev/null +++ b/packages/sdk/src/internal/utils/uuid.ts @@ -0,0 +1,17 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * https://stackoverflow.com/a/2117523 + */ +export let uuid4 = function () { + const { crypto } = globalThis as any; + if (crypto?.randomUUID) { + uuid4 = crypto.randomUUID.bind(crypto); + return crypto.randomUUID(); + } + const u8 = new Uint8Array(1); + const randomByte = crypto ? () => crypto.getRandomValues(u8)[0]! : () => (Math.random() * 0xff) & 0xff; + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => + (+c ^ (randomByte() & (15 >> (+c / 4)))).toString(16), + ); +}; diff --git a/packages/sdk/src/internal/utils/values.ts b/packages/sdk/src/internal/utils/values.ts new file mode 100644 index 000000000..b2421fd88 --- /dev/null +++ b/packages/sdk/src/internal/utils/values.ts @@ -0,0 +1,105 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { OpencodeError } from '../../core/error'; + +// https://url.spec.whatwg.org/#url-scheme-string +const startsWithSchemeRegexp = /^[a-z][a-z0-9+.-]*:/i; + +export const isAbsoluteURL = (url: string): boolean => { + return startsWithSchemeRegexp.test(url); +}; + +export let isArray = (val: unknown): val is unknown[] => ((isArray = Array.isArray), isArray(val)); +export let isReadonlyArray = isArray as (val: unknown) => val is readonly unknown[]; + +/** Returns an object if the given value isn't an object, otherwise returns as-is */ +export function maybeObj(x: unknown): object { + if (typeof x !== 'object') { + return {}; + } + + return x ?? {}; +} + +// https://stackoverflow.com/a/34491287 +export function isEmptyObj(obj: Object | null | undefined): boolean { + if (!obj) return true; + for (const _k in obj) return false; + return true; +} + +// https://eslint.org/docs/latest/rules/no-prototype-builtins +export function hasOwn<T extends object = object>(obj: T, key: PropertyKey): key is keyof T { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +export function isObj(obj: unknown): obj is Record<string, unknown> { + return obj != null && typeof obj === 'object' && !Array.isArray(obj); +} + +export const ensurePresent = <T>(value: T | null | undefined): T => { + if (value == null) { + throw new OpencodeError(`Expected a value to be given but received ${value} instead.`); + } + + return value; +}; + +export const validatePositiveInteger = (name: string, n: unknown): number => { + if (typeof n !== 'number' || !Number.isInteger(n)) { + throw new OpencodeError(`${name} must be an integer`); + } + if (n < 0) { + throw new OpencodeError(`${name} must be a positive integer`); + } + return n; +}; + +export const coerceInteger = (value: unknown): number => { + if (typeof value === 'number') return Math.round(value); + if (typeof value === 'string') return parseInt(value, 10); + + throw new OpencodeError(`Could not coerce ${value} (type: ${typeof value}) into a number`); +}; + +export const coerceFloat = (value: unknown): number => { + if (typeof value === 'number') return value; + if (typeof value === 'string') return parseFloat(value); + + throw new OpencodeError(`Could not coerce ${value} (type: ${typeof value}) into a number`); +}; + +export const coerceBoolean = (value: unknown): boolean => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === 'true'; + return Boolean(value); +}; + +export const maybeCoerceInteger = (value: unknown): number | undefined => { + if (value === undefined) { + return undefined; + } + return coerceInteger(value); +}; + +export const maybeCoerceFloat = (value: unknown): number | undefined => { + if (value === undefined) { + return undefined; + } + return coerceFloat(value); +}; + +export const maybeCoerceBoolean = (value: unknown): boolean | undefined => { + if (value === undefined) { + return undefined; + } + return coerceBoolean(value); +}; + +export const safeJSON = (text: string) => { + try { + return JSON.parse(text); + } catch (err) { + return undefined; + } +}; diff --git a/packages/sdk/src/lib/.keep b/packages/sdk/src/lib/.keep new file mode 100644 index 000000000..7554f8b20 --- /dev/null +++ b/packages/sdk/src/lib/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store custom files to expand the SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. diff --git a/packages/sdk/src/resource.ts b/packages/sdk/src/resource.ts new file mode 100644 index 000000000..363e3516b --- /dev/null +++ b/packages/sdk/src/resource.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/resource instead */ +export * from './core/resource'; diff --git a/packages/sdk/src/resources.ts b/packages/sdk/src/resources.ts new file mode 100644 index 000000000..b283d5781 --- /dev/null +++ b/packages/sdk/src/resources.ts @@ -0,0 +1 @@ +export * from './resources/index'; diff --git a/packages/sdk/src/resources/app.ts b/packages/sdk/src/resources/app.ts new file mode 100644 index 000000000..69991a3fe --- /dev/null +++ b/packages/sdk/src/resources/app.ts @@ -0,0 +1,190 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import { APIPromise } from '../core/api-promise'; +import { RequestOptions } from '../internal/request-options'; + +export class AppResource extends APIResource { + /** + * Get app info + */ + get(options?: RequestOptions): APIPromise<App> { + return this._client.get('/app', options); + } + + /** + * Initialize the app + */ + init(options?: RequestOptions): APIPromise<AppInitResponse> { + return this._client.post('/app/init', options); + } + + /** + * Write a log entry to the server logs + */ + log(body: AppLogParams, options?: RequestOptions): APIPromise<AppLogResponse> { + return this._client.post('/log', { body, ...options }); + } + + /** + * List all modes + */ + modes(options?: RequestOptions): APIPromise<AppModesResponse> { + return this._client.get('/mode', options); + } + + /** + * List all providers + */ + providers(options?: RequestOptions): APIPromise<AppProvidersResponse> { + return this._client.get('/config/providers', options); + } +} + +export interface App { + git: boolean; + + hostname: string; + + path: App.Path; + + time: App.Time; +} + +export namespace App { + export interface Path { + config: string; + + cwd: string; + + data: string; + + root: string; + + state: string; + } + + export interface Time { + initialized?: number; + } +} + +export interface Mode { + name: string; + + tools: { [key: string]: boolean }; + + model?: Mode.Model; + + prompt?: string; +} + +export namespace Mode { + export interface Model { + modelID: string; + + providerID: string; + } +} + +export interface Model { + id: string; + + attachment: boolean; + + cost: Model.Cost; + + limit: Model.Limit; + + name: string; + + options: { [key: string]: unknown }; + + reasoning: boolean; + + release_date: string; + + temperature: boolean; + + tool_call: boolean; +} + +export namespace Model { + export interface Cost { + input: number; + + output: number; + + cache_read?: number; + + cache_write?: number; + } + + export interface Limit { + context: number; + + output: number; + } +} + +export interface Provider { + id: string; + + env: Array<string>; + + models: { [key: string]: Model }; + + name: string; + + api?: string; + + npm?: string; +} + +export type AppInitResponse = boolean; + +export type AppLogResponse = boolean; + +export type AppModesResponse = Array<Mode>; + +export interface AppProvidersResponse { + default: { [key: string]: string }; + + providers: Array<Provider>; +} + +export interface AppLogParams { + /** + * Log level + */ + level: 'debug' | 'info' | 'error' | 'warn'; + + /** + * Log message + */ + message: string; + + /** + * Service name for the log entry + */ + service: string; + + /** + * Additional metadata for the log entry + */ + extra?: { [key: string]: unknown }; +} + +export declare namespace AppResource { + export { + type App as App, + type Mode as Mode, + type Model as Model, + type Provider as Provider, + type AppInitResponse as AppInitResponse, + type AppLogResponse as AppLogResponse, + type AppModesResponse as AppModesResponse, + type AppProvidersResponse as AppProvidersResponse, + type AppLogParams as AppLogParams, + }; +} diff --git a/packages/sdk/src/resources/config.ts b/packages/sdk/src/resources/config.ts new file mode 100644 index 000000000..e1fc09de5 --- /dev/null +++ b/packages/sdk/src/resources/config.ts @@ -0,0 +1,438 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import * as ConfigAPI from './config'; +import { APIPromise } from '../core/api-promise'; +import { RequestOptions } from '../internal/request-options'; + +export class ConfigResource extends APIResource { + /** + * Get config info + */ + get(options?: RequestOptions): APIPromise<Config> { + return this._client.get('/config', options); + } +} + +export interface Config { + /** + * JSON schema reference for configuration validation + */ + $schema?: string; + + /** + * @deprecated Use 'share' field instead. Share newly created sessions + * automatically + */ + autoshare?: boolean; + + /** + * Automatically update to the latest version + */ + autoupdate?: boolean; + + /** + * Disable providers that are loaded automatically + */ + disabled_providers?: Array<string>; + + experimental?: Config.Experimental; + + /** + * Additional instruction files or patterns to include + */ + instructions?: Array<string>; + + /** + * Custom keybind configurations + */ + keybinds?: KeybindsConfig; + + /** + * @deprecated Always uses stretch layout. + */ + layout?: 'auto' | 'stretch'; + + /** + * MCP (Model Context Protocol) server configurations + */ + mcp?: { [key: string]: McpLocalConfig | McpRemoteConfig }; + + /** + * Modes configuration, see https://opencode.ai/docs/modes + */ + mode?: Config.Mode; + + /** + * Model to use in the format of provider/model, eg anthropic/claude-2 + */ + model?: string; + + /** + * Custom provider configurations and model overrides + */ + provider?: { [key: string]: Config.Provider }; + + /** + * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' + * enables automatic sharing, 'disabled' disables all sharing + */ + share?: 'manual' | 'auto' | 'disabled'; + + /** + * Small model to use for tasks like summarization and title generation in the + * format of provider/model + */ + small_model?: string; + + /** + * Theme name to use for the interface + */ + theme?: string; + + /** + * Custom username to display in conversations instead of system username + */ + username?: string; +} + +export namespace Config { + export interface Experimental { + hook?: Experimental.Hook; + } + + export namespace Experimental { + export interface Hook { + file_edited?: { [key: string]: Array<Hook.FileEdited> }; + + session_completed?: Array<Hook.SessionCompleted>; + } + + export namespace Hook { + export interface FileEdited { + command: Array<string>; + + environment?: { [key: string]: string }; + } + + export interface SessionCompleted { + command: Array<string>; + + environment?: { [key: string]: string }; + } + } + } + + /** + * Modes configuration, see https://opencode.ai/docs/modes + */ + export interface Mode { + build?: ConfigAPI.ModeConfig; + + plan?: ConfigAPI.ModeConfig; + + [k: string]: ConfigAPI.ModeConfig | undefined; + } + + export interface Provider { + models: { [key: string]: Provider.Models }; + + id?: string; + + api?: string; + + env?: Array<string>; + + name?: string; + + npm?: string; + + options?: { [key: string]: unknown }; + } + + export namespace Provider { + export interface Models { + id?: string; + + attachment?: boolean; + + cost?: Models.Cost; + + limit?: Models.Limit; + + name?: string; + + options?: { [key: string]: unknown }; + + reasoning?: boolean; + + release_date?: string; + + temperature?: boolean; + + tool_call?: boolean; + } + + export namespace Models { + export interface Cost { + input: number; + + output: number; + + cache_read?: number; + + cache_write?: number; + } + + export interface Limit { + context: number; + + output: number; + } + } + } +} + +export interface KeybindsConfig { + /** + * Exit the application + */ + app_exit: string; + + /** + * Show help dialog + */ + app_help: string; + + /** + * Open external editor + */ + editor_open: string; + + /** + * Close file + */ + file_close: string; + + /** + * Split/unified diff + */ + file_diff_toggle: string; + + /** + * List files + */ + file_list: string; + + /** + * Search file + */ + file_search: string; + + /** + * Clear input field + */ + input_clear: string; + + /** + * Insert newline in input + */ + input_newline: string; + + /** + * Paste from clipboard + */ + input_paste: string; + + /** + * Submit input + */ + input_submit: string; + + /** + * Leader key for keybind combinations + */ + leader: string; + + /** + * Copy message + */ + messages_copy: string; + + /** + * Navigate to first message + */ + messages_first: string; + + /** + * Scroll messages down by half page + */ + messages_half_page_down: string; + + /** + * Scroll messages up by half page + */ + messages_half_page_up: string; + + /** + * Navigate to last message + */ + messages_last: string; + + /** + * Toggle layout + */ + messages_layout_toggle: string; + + /** + * Navigate to next message + */ + messages_next: string; + + /** + * Scroll messages down by one page + */ + messages_page_down: string; + + /** + * Scroll messages up by one page + */ + messages_page_up: string; + + /** + * Navigate to previous message + */ + messages_previous: string; + + /** + * Revert message + */ + messages_revert: string; + + /** + * List available models + */ + model_list: string; + + /** + * Create/update AGENTS.md + */ + project_init: string; + + /** + * Compact the session + */ + session_compact: string; + + /** + * Export session to editor + */ + session_export: string; + + /** + * Interrupt current session + */ + session_interrupt: string; + + /** + * List all sessions + */ + session_list: string; + + /** + * Create a new session + */ + session_new: string; + + /** + * Share current session + */ + session_share: string; + + /** + * Unshare current session + */ + session_unshare: string; + + /** + * Next mode + */ + switch_mode: string; + + /** + * Previous Mode + */ + switch_mode_reverse: string; + + /** + * List available themes + */ + theme_list: string; + + /** + * Toggle tool details + */ + tool_details: string; +} + +export interface McpLocalConfig { + /** + * Command and arguments to run the MCP server + */ + command: Array<string>; + + /** + * Type of MCP server connection + */ + type: 'local'; + + /** + * Enable or disable the MCP server on startup + */ + enabled?: boolean; + + /** + * Environment variables to set when running the MCP server + */ + environment?: { [key: string]: string }; +} + +export interface McpRemoteConfig { + /** + * Type of MCP server connection + */ + type: 'remote'; + + /** + * URL of the remote MCP server + */ + url: string; + + /** + * Enable or disable the MCP server on startup + */ + enabled?: boolean; + + /** + * Headers to send with the request + */ + headers?: { [key: string]: string }; +} + +export interface ModeConfig { + model?: string; + + prompt?: string; + + tools?: { [key: string]: boolean }; +} + +export declare namespace ConfigResource { + export { + type Config as Config, + type KeybindsConfig as KeybindsConfig, + type McpLocalConfig as McpLocalConfig, + type McpRemoteConfig as McpRemoteConfig, + type ModeConfig as ModeConfig, + }; +} diff --git a/packages/sdk/src/resources/event.ts b/packages/sdk/src/resources/event.ts new file mode 100644 index 000000000..a3dd9288c --- /dev/null +++ b/packages/sdk/src/resources/event.ts @@ -0,0 +1,243 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import * as SessionAPI from './session'; +import * as Shared from './shared'; +import { APIPromise } from '../core/api-promise'; +import { Stream } from '../core/streaming'; +import { RequestOptions } from '../internal/request-options'; + +export class Event extends APIResource { + /** + * Get events + */ + list(options?: RequestOptions): APIPromise<Stream<EventListResponse>> { + return this._client.get('/event', { ...options, stream: true }) as APIPromise<Stream<EventListResponse>>; + } +} + +export type EventListResponse = + | EventListResponse.EventLspClientDiagnostics + | EventListResponse.EventPermissionUpdated + | EventListResponse.EventFileEdited + | EventListResponse.EventInstallationUpdated + | EventListResponse.EventMessageUpdated + | EventListResponse.EventMessageRemoved + | EventListResponse.EventMessagePartUpdated + | EventListResponse.EventStorageWrite + | EventListResponse.EventSessionUpdated + | EventListResponse.EventSessionDeleted + | EventListResponse.EventSessionIdle + | EventListResponse.EventSessionError + | EventListResponse.EventFileWatcherUpdated + | EventListResponse.EventIdeInstalled; + +export namespace EventListResponse { + export interface EventLspClientDiagnostics { + properties: EventLspClientDiagnostics.Properties; + + type: 'lsp.client.diagnostics'; + } + + export namespace EventLspClientDiagnostics { + export interface Properties { + path: string; + + serverID: string; + } + } + + export interface EventPermissionUpdated { + properties: EventPermissionUpdated.Properties; + + type: 'permission.updated'; + } + + export namespace EventPermissionUpdated { + export interface Properties { + id: string; + + metadata: { [key: string]: unknown }; + + sessionID: string; + + time: Properties.Time; + + title: string; + } + + export namespace Properties { + export interface Time { + created: number; + } + } + } + + export interface EventFileEdited { + properties: EventFileEdited.Properties; + + type: 'file.edited'; + } + + export namespace EventFileEdited { + export interface Properties { + file: string; + } + } + + export interface EventInstallationUpdated { + properties: EventInstallationUpdated.Properties; + + type: 'installation.updated'; + } + + export namespace EventInstallationUpdated { + export interface Properties { + version: string; + } + } + + export interface EventMessageUpdated { + properties: EventMessageUpdated.Properties; + + type: 'message.updated'; + } + + export namespace EventMessageUpdated { + export interface Properties { + info: SessionAPI.Message; + } + } + + export interface EventMessageRemoved { + properties: EventMessageRemoved.Properties; + + type: 'message.removed'; + } + + export namespace EventMessageRemoved { + export interface Properties { + messageID: string; + + sessionID: string; + } + } + + export interface EventMessagePartUpdated { + properties: EventMessagePartUpdated.Properties; + + type: 'message.part.updated'; + } + + export namespace EventMessagePartUpdated { + export interface Properties { + part: SessionAPI.Part; + } + } + + export interface EventStorageWrite { + properties: EventStorageWrite.Properties; + + type: 'storage.write'; + } + + export namespace EventStorageWrite { + export interface Properties { + key: string; + + content?: unknown; + } + } + + export interface EventSessionUpdated { + properties: EventSessionUpdated.Properties; + + type: 'session.updated'; + } + + export namespace EventSessionUpdated { + export interface Properties { + info: SessionAPI.Session; + } + } + + export interface EventSessionDeleted { + properties: EventSessionDeleted.Properties; + + type: 'session.deleted'; + } + + export namespace EventSessionDeleted { + export interface Properties { + info: SessionAPI.Session; + } + } + + export interface EventSessionIdle { + properties: EventSessionIdle.Properties; + + type: 'session.idle'; + } + + export namespace EventSessionIdle { + export interface Properties { + sessionID: string; + } + } + + export interface EventSessionError { + properties: EventSessionError.Properties; + + type: 'session.error'; + } + + export namespace EventSessionError { + export interface Properties { + error?: + | Shared.ProviderAuthError + | Shared.UnknownError + | Properties.MessageOutputLengthError + | Shared.MessageAbortedError; + + sessionID?: string; + } + + export namespace Properties { + export interface MessageOutputLengthError { + data: unknown; + + name: 'MessageOutputLengthError'; + } + } + } + + export interface EventFileWatcherUpdated { + properties: EventFileWatcherUpdated.Properties; + + type: 'file.watcher.updated'; + } + + export namespace EventFileWatcherUpdated { + export interface Properties { + event: 'rename' | 'change'; + + file: string; + } + } + + export interface EventIdeInstalled { + properties: EventIdeInstalled.Properties; + + type: 'ide.installed'; + } + + export namespace EventIdeInstalled { + export interface Properties { + ide: string; + } + } +} + +export declare namespace Event { + export { type EventListResponse as EventListResponse }; +} diff --git a/packages/sdk/src/resources/file.ts b/packages/sdk/src/resources/file.ts new file mode 100644 index 000000000..fd7bdac69 --- /dev/null +++ b/packages/sdk/src/resources/file.ts @@ -0,0 +1,52 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import { APIPromise } from '../core/api-promise'; +import { RequestOptions } from '../internal/request-options'; + +export class FileResource extends APIResource { + /** + * Read a file + */ + read(query: FileReadParams, options?: RequestOptions): APIPromise<FileReadResponse> { + return this._client.get('/file', { query, ...options }); + } + + /** + * Get file status + */ + status(options?: RequestOptions): APIPromise<FileStatusResponse> { + return this._client.get('/file/status', options); + } +} + +export interface File { + added: number; + + path: string; + + removed: number; + + status: 'added' | 'deleted' | 'modified'; +} + +export interface FileReadResponse { + content: string; + + type: 'raw' | 'patch'; +} + +export type FileStatusResponse = Array<File>; + +export interface FileReadParams { + path: string; +} + +export declare namespace FileResource { + export { + type File as File, + type FileReadResponse as FileReadResponse, + type FileStatusResponse as FileStatusResponse, + type FileReadParams as FileReadParams, + }; +} diff --git a/packages/sdk/src/resources/find.ts b/packages/sdk/src/resources/find.ts new file mode 100644 index 000000000..13062c426 --- /dev/null +++ b/packages/sdk/src/resources/find.ts @@ -0,0 +1,133 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import { APIPromise } from '../core/api-promise'; +import { RequestOptions } from '../internal/request-options'; + +export class Find extends APIResource { + /** + * Find files + */ + files(query: FindFilesParams, options?: RequestOptions): APIPromise<FindFilesResponse> { + return this._client.get('/find/file', { query, ...options }); + } + + /** + * Find workspace symbols + */ + symbols(query: FindSymbolsParams, options?: RequestOptions): APIPromise<FindSymbolsResponse> { + return this._client.get('/find/symbol', { query, ...options }); + } + + /** + * Find text in files + */ + text(query: FindTextParams, options?: RequestOptions): APIPromise<FindTextResponse> { + return this._client.get('/find', { query, ...options }); + } +} + +export interface Match { + absolute_offset: number; + + line_number: number; + + lines: Match.Lines; + + path: Match.Path; + + submatches: Array<Match.Submatch>; +} + +export namespace Match { + export interface Lines { + text: string; + } + + export interface Path { + text: string; + } + + export interface Submatch { + end: number; + + match: Submatch.Match; + + start: number; + } + + export namespace Submatch { + export interface Match { + text: string; + } + } +} + +export interface Symbol { + kind: number; + + location: Symbol.Location; + + name: string; +} + +export namespace Symbol { + export interface Location { + range: Location.Range; + + uri: string; + } + + export namespace Location { + export interface Range { + end: Range.End; + + start: Range.Start; + } + + export namespace Range { + export interface End { + character: number; + + line: number; + } + + export interface Start { + character: number; + + line: number; + } + } + } +} + +export type FindFilesResponse = Array<string>; + +export type FindSymbolsResponse = Array<Symbol>; + +export type FindTextResponse = Array<Match>; + +export interface FindFilesParams { + query: string; +} + +export interface FindSymbolsParams { + query: string; +} + +export interface FindTextParams { + pattern: string; +} + +export declare namespace Find { + export { + type Match as Match, + type Symbol as Symbol, + type FindFilesResponse as FindFilesResponse, + type FindSymbolsResponse as FindSymbolsResponse, + type FindTextResponse as FindTextResponse, + type FindFilesParams as FindFilesParams, + type FindSymbolsParams as FindSymbolsParams, + type FindTextParams as FindTextParams, + }; +} diff --git a/packages/sdk/src/resources/index.ts b/packages/sdk/src/resources/index.ts new file mode 100644 index 000000000..50df8ff46 --- /dev/null +++ b/packages/sdk/src/resources/index.ts @@ -0,0 +1,81 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from './shared'; +export { + AppResource, + type App, + type Mode, + type Model, + type Provider, + type AppInitResponse, + type AppLogResponse, + type AppModesResponse, + type AppProvidersResponse, + type AppLogParams, +} from './app'; +export { + ConfigResource, + type Config, + type KeybindsConfig, + type McpLocalConfig, + type McpRemoteConfig, + type ModeConfig, +} from './config'; +export { Event, type EventListResponse } from './event'; +export { + FileResource, + type File, + type FileReadResponse, + type FileStatusResponse, + type FileReadParams, +} from './file'; +export { + Find, + type Match, + type Symbol, + type FindFilesResponse, + type FindSymbolsResponse, + type FindTextResponse, + type FindFilesParams, + type FindSymbolsParams, + type FindTextParams, +} from './find'; +export { + SessionResource, + type AssistantMessage, + type FilePart, + type FilePartInput, + type FilePartSource, + type FilePartSourceText, + type FileSource, + type Message, + type Part, + type Session, + type SnapshotPart, + type StepFinishPart, + type StepStartPart, + type SymbolSource, + type TextPart, + type TextPartInput, + type ToolPart, + type ToolStateCompleted, + type ToolStateError, + type ToolStatePending, + type ToolStateRunning, + type UserMessage, + type SessionListResponse, + type SessionDeleteResponse, + type SessionAbortResponse, + type SessionInitResponse, + type SessionMessagesResponse, + type SessionSummarizeResponse, + type SessionChatParams, + type SessionInitParams, + type SessionSummarizeParams, +} from './session'; +export { + Tui, + type TuiAppendPromptResponse, + type TuiOpenHelpResponse, + type TuiAppendPromptParams, +} from './tui'; diff --git a/packages/sdk/src/resources/session.ts b/packages/sdk/src/resources/session.ts new file mode 100644 index 000000000..6e160321d --- /dev/null +++ b/packages/sdk/src/resources/session.ts @@ -0,0 +1,555 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import * as SessionAPI from './session'; +import * as Shared from './shared'; +import { APIPromise } from '../core/api-promise'; +import { RequestOptions } from '../internal/request-options'; +import { path } from '../internal/utils/path'; + +export class SessionResource extends APIResource { + /** + * Create a new session + */ + create(options?: RequestOptions): APIPromise<Session> { + return this._client.post('/session', options); + } + + /** + * List all sessions + */ + list(options?: RequestOptions): APIPromise<SessionListResponse> { + return this._client.get('/session', options); + } + + /** + * Delete a session and all its data + */ + delete(id: string, options?: RequestOptions): APIPromise<SessionDeleteResponse> { + return this._client.delete(path`/session/${id}`, options); + } + + /** + * Abort a session + */ + abort(id: string, options?: RequestOptions): APIPromise<SessionAbortResponse> { + return this._client.post(path`/session/${id}/abort`, options); + } + + /** + * Create and send a new message to a session + */ + chat(id: string, body: SessionChatParams, options?: RequestOptions): APIPromise<AssistantMessage> { + return this._client.post(path`/session/${id}/message`, { body, ...options }); + } + + /** + * Analyze the app and create an AGENTS.md file + */ + init(id: string, body: SessionInitParams, options?: RequestOptions): APIPromise<SessionInitResponse> { + return this._client.post(path`/session/${id}/init`, { body, ...options }); + } + + /** + * List messages for a session + */ + messages(id: string, options?: RequestOptions): APIPromise<SessionMessagesResponse> { + return this._client.get(path`/session/${id}/message`, options); + } + + /** + * Share a session + */ + share(id: string, options?: RequestOptions): APIPromise<Session> { + return this._client.post(path`/session/${id}/share`, options); + } + + /** + * Summarize the session + */ + summarize( + id: string, + body: SessionSummarizeParams, + options?: RequestOptions, + ): APIPromise<SessionSummarizeResponse> { + return this._client.post(path`/session/${id}/summarize`, { body, ...options }); + } + + /** + * Unshare the session + */ + unshare(id: string, options?: RequestOptions): APIPromise<Session> { + return this._client.delete(path`/session/${id}/share`, options); + } +} + +export interface AssistantMessage { + id: string; + + cost: number; + + modelID: string; + + path: AssistantMessage.Path; + + providerID: string; + + role: 'assistant'; + + sessionID: string; + + system: Array<string>; + + time: AssistantMessage.Time; + + tokens: AssistantMessage.Tokens; + + error?: + | Shared.ProviderAuthError + | Shared.UnknownError + | AssistantMessage.MessageOutputLengthError + | Shared.MessageAbortedError; + + summary?: boolean; +} + +export namespace AssistantMessage { + export interface Path { + cwd: string; + + root: string; + } + + export interface Time { + created: number; + + completed?: number; + } + + export interface Tokens { + cache: Tokens.Cache; + + input: number; + + output: number; + + reasoning: number; + } + + export namespace Tokens { + export interface Cache { + read: number; + + write: number; + } + } + + export interface MessageOutputLengthError { + data: unknown; + + name: 'MessageOutputLengthError'; + } +} + +export interface FilePart { + id: string; + + messageID: string; + + mime: string; + + sessionID: string; + + type: 'file'; + + url: string; + + filename?: string; + + source?: FilePartSource; +} + +export interface FilePartInput { + mime: string; + + type: 'file'; + + url: string; + + id?: string; + + filename?: string; + + source?: FilePartSource; +} + +export type FilePartSource = FileSource | SymbolSource; + +export interface FilePartSourceText { + end: number; + + start: number; + + value: string; +} + +export interface FileSource { + path: string; + + text: FilePartSourceText; + + type: 'file'; +} + +export type Message = UserMessage | AssistantMessage; + +export type Part = TextPart | FilePart | ToolPart | StepStartPart | StepFinishPart | SnapshotPart; + +export interface Session { + id: string; + + time: Session.Time; + + title: string; + + version: string; + + parentID?: string; + + revert?: Session.Revert; + + share?: Session.Share; +} + +export namespace Session { + export interface Time { + created: number; + + updated: number; + } + + export interface Revert { + messageID: string; + + part: number; + + snapshot?: string; + } + + export interface Share { + url: string; + } +} + +export interface SnapshotPart { + id: string; + + messageID: string; + + sessionID: string; + + snapshot: string; + + type: 'snapshot'; +} + +export interface StepFinishPart { + id: string; + + cost: number; + + messageID: string; + + sessionID: string; + + tokens: StepFinishPart.Tokens; + + type: 'step-finish'; +} + +export namespace StepFinishPart { + export interface Tokens { + cache: Tokens.Cache; + + input: number; + + output: number; + + reasoning: number; + } + + export namespace Tokens { + export interface Cache { + read: number; + + write: number; + } + } +} + +export interface StepStartPart { + id: string; + + messageID: string; + + sessionID: string; + + type: 'step-start'; +} + +export interface SymbolSource { + kind: number; + + name: string; + + path: string; + + range: SymbolSource.Range; + + text: FilePartSourceText; + + type: 'symbol'; +} + +export namespace SymbolSource { + export interface Range { + end: Range.End; + + start: Range.Start; + } + + export namespace Range { + export interface End { + character: number; + + line: number; + } + + export interface Start { + character: number; + + line: number; + } + } +} + +export interface TextPart { + id: string; + + messageID: string; + + sessionID: string; + + text: string; + + type: 'text'; + + synthetic?: boolean; + + time?: TextPart.Time; +} + +export namespace TextPart { + export interface Time { + start: number; + + end?: number; + } +} + +export interface TextPartInput { + text: string; + + type: 'text'; + + id?: string; + + synthetic?: boolean; + + time?: TextPartInput.Time; +} + +export namespace TextPartInput { + export interface Time { + start: number; + + end?: number; + } +} + +export interface ToolPart { + id: string; + + callID: string; + + messageID: string; + + sessionID: string; + + state: ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError; + + tool: string; + + type: 'tool'; +} + +export interface ToolStateCompleted { + input: { [key: string]: unknown }; + + metadata: { [key: string]: unknown }; + + output: string; + + status: 'completed'; + + time: ToolStateCompleted.Time; + + title: string; +} + +export namespace ToolStateCompleted { + export interface Time { + end: number; + + start: number; + } +} + +export interface ToolStateError { + error: string; + + input: { [key: string]: unknown }; + + status: 'error'; + + time: ToolStateError.Time; +} + +export namespace ToolStateError { + export interface Time { + end: number; + + start: number; + } +} + +export interface ToolStatePending { + status: 'pending'; +} + +export interface ToolStateRunning { + status: 'running'; + + time: ToolStateRunning.Time; + + input?: unknown; + + metadata?: { [key: string]: unknown }; + + title?: string; +} + +export namespace ToolStateRunning { + export interface Time { + start: number; + } +} + +export interface UserMessage { + id: string; + + role: 'user'; + + sessionID: string; + + time: UserMessage.Time; +} + +export namespace UserMessage { + export interface Time { + created: number; + } +} + +export type SessionListResponse = Array<Session>; + +export type SessionDeleteResponse = boolean; + +export type SessionAbortResponse = boolean; + +export type SessionInitResponse = boolean; + +export type SessionMessagesResponse = Array<SessionMessagesResponse.SessionMessagesResponseItem>; + +export namespace SessionMessagesResponse { + export interface SessionMessagesResponseItem { + info: SessionAPI.Message; + + parts: Array<SessionAPI.Part>; + } +} + +export type SessionSummarizeResponse = boolean; + +export interface SessionChatParams { + modelID: string; + + parts: Array<TextPartInput | FilePartInput>; + + providerID: string; + + messageID?: string; + + mode?: string; + + tools?: { [key: string]: boolean }; +} + +export interface SessionInitParams { + messageID: string; + + modelID: string; + + providerID: string; +} + +export interface SessionSummarizeParams { + modelID: string; + + providerID: string; +} + +export declare namespace SessionResource { + export { + type AssistantMessage as AssistantMessage, + type FilePart as FilePart, + type FilePartInput as FilePartInput, + type FilePartSource as FilePartSource, + type FilePartSourceText as FilePartSourceText, + type FileSource as FileSource, + type Message as Message, + type Part as Part, + type Session as Session, + type SnapshotPart as SnapshotPart, + type StepFinishPart as StepFinishPart, + type StepStartPart as StepStartPart, + type SymbolSource as SymbolSource, + type TextPart as TextPart, + type TextPartInput as TextPartInput, + type ToolPart as ToolPart, + type ToolStateCompleted as ToolStateCompleted, + type ToolStateError as ToolStateError, + type ToolStatePending as ToolStatePending, + type ToolStateRunning as ToolStateRunning, + type UserMessage as UserMessage, + type SessionListResponse as SessionListResponse, + type SessionDeleteResponse as SessionDeleteResponse, + type SessionAbortResponse as SessionAbortResponse, + type SessionInitResponse as SessionInitResponse, + type SessionMessagesResponse as SessionMessagesResponse, + type SessionSummarizeResponse as SessionSummarizeResponse, + type SessionChatParams as SessionChatParams, + type SessionInitParams as SessionInitParams, + type SessionSummarizeParams as SessionSummarizeParams, + }; +} diff --git a/packages/sdk/src/resources/shared.ts b/packages/sdk/src/resources/shared.ts new file mode 100644 index 000000000..31378f6fe --- /dev/null +++ b/packages/sdk/src/resources/shared.ts @@ -0,0 +1,33 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export interface MessageAbortedError { + data: unknown; + + name: 'MessageAbortedError'; +} + +export interface ProviderAuthError { + data: ProviderAuthError.Data; + + name: 'ProviderAuthError'; +} + +export namespace ProviderAuthError { + export interface Data { + message: string; + + providerID: string; + } +} + +export interface UnknownError { + data: UnknownError.Data; + + name: 'UnknownError'; +} + +export namespace UnknownError { + export interface Data { + message: string; + } +} diff --git a/packages/sdk/src/resources/tui.ts b/packages/sdk/src/resources/tui.ts new file mode 100644 index 000000000..c7efbdd6c --- /dev/null +++ b/packages/sdk/src/resources/tui.ts @@ -0,0 +1,37 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import { APIPromise } from '../core/api-promise'; +import { RequestOptions } from '../internal/request-options'; + +export class Tui extends APIResource { + /** + * Append prompt to the TUI + */ + appendPrompt(body: TuiAppendPromptParams, options?: RequestOptions): APIPromise<TuiAppendPromptResponse> { + return this._client.post('/tui/append-prompt', { body, ...options }); + } + + /** + * Open the help dialog + */ + openHelp(options?: RequestOptions): APIPromise<TuiOpenHelpResponse> { + return this._client.post('/tui/open-help', options); + } +} + +export type TuiAppendPromptResponse = boolean; + +export type TuiOpenHelpResponse = boolean; + +export interface TuiAppendPromptParams { + text: string; +} + +export declare namespace Tui { + export { + type TuiAppendPromptResponse as TuiAppendPromptResponse, + type TuiOpenHelpResponse as TuiOpenHelpResponse, + type TuiAppendPromptParams as TuiAppendPromptParams, + }; +} diff --git a/packages/sdk/src/streaming.ts b/packages/sdk/src/streaming.ts new file mode 100644 index 000000000..9e6da1063 --- /dev/null +++ b/packages/sdk/src/streaming.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/streaming instead */ +export * from './core/streaming'; diff --git a/packages/sdk/src/uploads.ts b/packages/sdk/src/uploads.ts new file mode 100644 index 000000000..b2ef64710 --- /dev/null +++ b/packages/sdk/src/uploads.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/uploads instead */ +export * from './core/uploads'; diff --git a/packages/sdk/src/version.ts b/packages/sdk/src/version.ts new file mode 100644 index 000000000..48ca0ae07 --- /dev/null +++ b/packages/sdk/src/version.ts @@ -0,0 +1 @@ +export const VERSION = '0.1.0-alpha.20'; // x-release-please-version |
