All files / src client.js

100% Statements 115/115
96.77% Branches 30/31
100% Functions 6/6
100% Lines 115/115

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 1165x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 7x 7x 7x 7x 7x 7x 7x 7x 5x 5x 5x 5x 5x 5x 5x 5x 6x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x 6x 2x 2x 2x 3x 3x 3x 6x 6x 6x 6x 6x 6x 6x 6x 6x 5x 5x 5x 5x 5x 5x 5x 2x 2x 1x 1x 1x 2x 5x 5x 5x 5x 5x 5x 5x 6x 6x 6x 6x 6x 6x 5x 5x 5x 5x 5x 5x 5x 10x 10x 10x 10x 1x 10x 5x 5x 2x 2x 2x 2x 2x  
// @ts-check
 
/**
 * @typedef {{ [path: string]: string }} FileMap
 * @typedef {{ toFiles: () => FileMap }} FileBackedModel
 * @typedef {{ filePath: string, kind: "domain" | "problem" | "model", content: string }} InlineInput
 * @typedef {{ apiKey?: string, baseUrl?: string, fetch?: typeof globalThis.fetch }} ClientOptions
 * @typedef {{ computeTierId: string, timeLimitS?: number }} SubmitOptions
 * @typedef {{ jobId: string, status: string, effectiveTimeLimitS: number, maxCreditCost?: number }} SubmitResult
 */
 
export class FeasblClient {
  /**
   * Create a Feasbl API client for direct model submission.
   * @param {ClientOptions} [options] Client configuration; `apiKey` defaults to `FEASBL_API_KEY`.
   */
  constructor(options = {}) {
    /** @type {string | undefined} */
    this.apiKey = options.apiKey ?? readApiKey();
    /** @type {string} */
    this.baseUrl = (options.baseUrl ?? "https://api.feasbl.com").replace(/\/$/, "");
    /** @type {typeof globalThis.fetch} */
    this.fetch = options.fetch ?? globalThis.fetch;
    if (!this.fetch) throw new Error("A fetch implementation is required");
  }
 
  /**
   * Submit a generated Jia or PDDL model directly to the Feasbl jobs API.
   * @param {FileBackedModel} model Object exposing `toFiles()`.
   * @param {SubmitOptions} options Compute tier and runtime limits for the job.
   * @returns {Promise<SubmitResult>}
   */
  async submit(model, options) {
    if (!this.apiKey) throw new Error("Missing Feasbl API key");
    const body = {
      computeTierId: options.computeTierId,
      timeLimitS: options.timeLimitS,
      inputs: filesToInputs(model.toFiles()).map(input => ({
        filePath: input.filePath,
        kind: input.kind,
        content: input.content,
      })),
    };
 
    const response = await this.fetch(`${this.baseUrl}/api/v1/jobs`, {
      method: "POST",
      headers: {
        authorization: `Bearer ${this.apiKey}`,
        "content-type": "application/json",
      },
      body: JSON.stringify(body),
    });
 
    if (!response.ok) {
      const text = await response.text();
      throw new Error(`Feasbl job submission failed: ${response.status} ${explainFailure(text)}`);
    }
 
    const payload = await response.json();
    return {
      jobId: String(payload.jobId ?? payload.job_id),
      status: String(payload.status),
      effectiveTimeLimitS: Number(payload.effectiveTimeLimitS ?? payload.effective_time_limit_s),
      maxCreditCost:
        payload.maxCreditCost === undefined && payload.max_credit_cost === undefined
          ? undefined
          : Number(payload.maxCreditCost ?? payload.max_credit_cost),
    };
  }
}
 
/**
 * Add context for common deployment/auth mismatches.
 * @param {string} responseText
 * @returns {string}
 */
function explainFailure(responseText) {
  if (responseText.includes('"reason":"missing_token"')) {
    return `${responseText} (the direct SDK job endpoint is probably not deployed at this base URL yet; the request hit a session-auth route)`;
  }
  return responseText;
}
 
/**
 * Convert generated SDK files into inline job inputs accepted by the API.
 * @param {FileMap} files Map from generated file path to file contents.
 * @returns {InlineInput[]}
 */
export function filesToInputs(files) {
  return Object.entries(files).map(([filePath, content]) => ({
    filePath,
    kind: inferKind(filePath),
    content,
  }));
}
 
/**
 * Infer the Feasbl artifact kind from a generated filename.
 * @param {string} filePath Generated file path, such as `model.jia` or `domain.pddl`.
 * @returns {"domain" | "problem" | "model"}
 */
export function inferKind(filePath) {
  const lower = filePath.toLowerCase();
  if (lower.endsWith(".jia")) return "model";
  if (lower.includes("domain") && lower.endsWith(".pddl")) return "domain";
  if (lower.includes("problem") && lower.endsWith(".pddl")) return "problem";
  throw new Error(`Cannot infer Feasbl input kind for ${filePath}`);
}
 
/** @returns {string | undefined} */
function readApiKey() {
  const globals = /** @type {Record<string, unknown>} */ (globalThis);
  const proc = /** @type {{ env?: { FEASBL_API_KEY?: string } } | undefined} */ (globals.process);
  return proc?.env?.FEASBL_API_KEY;
}