mirror of
https://github.com/sst/opencode.git
synced 2025-08-30 17:57:25 +00:00
862 lines
29 KiB
TypeScript
862 lines
29 KiB
TypeScript
// 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;
|
|
}
|