ts / src / jobs.ts
851 words
  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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
/**
 * Asynchronous processing jobs: enqueue, poll for status, then fetch the
 * result. Submitted content is never persisted; results are kept for 24
 * hours.
 *
 * Every job type has a payload builder (`domainAvailabilityBulk`,
 * `embedding`, `llmChat`, `llmRawCompletion`, `nlpAnalyze`,
 * `nlpDetectLanguage`); pass the built payload to `enqueue`, or use `run`
 * to enqueue, poll and fetch the result in one call.
 */
import type { Client } from './client.js';
import { configError, jobFailed } from './errors.js';
import * as http from './http.js';
import type { Body } from './types.js';

export type JobSpec = Body<'/jobs', 'post'>;
export type JobType = JobSpec['type'];
type Spec<T extends JobType> = Extract<JobSpec, { type: T }>;
type Options<T extends JobType, K extends keyof Spec<T>> = Readonly<Partial<Omit<Spec<T>, 'type' | K>>>;

/** POST /jobs — Enqueue a job (202 with the queued job). */
export const enqueue = (client: Client, spec: JobSpec) => http.post(client, '/jobs', { body: spec });

/** GET /jobs/{id} — Job status: queued/processing/succeeded/failed. */
export const get = (client: Client, id: string) => http.get(client, '/jobs/{id}', { params: { id } });

/** GET /jobs/{id}/result — Job result. 409 while still running, 410 once expired (24h). */
export const getResult = (client: Client, id: string) => http.get(client, '/jobs/{id}/result', { params: { id } });

// ---------------------------------------------------------------------------
// Payload builders (one per job type). Options merge first, so `type` and
// the primary input can never be overridden.

/** Check up to 100 domains in one job. */
export const domainAvailabilityBulk = (domains: readonly string[]): Spec<'domain-availability-bulk'> => ({
  type: 'domain-availability-bulk',
  domains: [...domains],
});

/** Embed INPUT text (`model` defaults server-side). */
export const embedding = (input: string, options: Options<'embedding', 'input'> = {}): Spec<'embedding'> => ({
  ...options,
  type: 'embedding',
  input,
});

export type ChatMessage = Spec<'llm-chat'>['messages'][number];

/** Chat completion over MESSAGES. */
export const llmChat = (
  messages: readonly ChatMessage[],
  options: Options<'llm-chat', 'messages'> = {},
): Spec<'llm-chat'> => ({ ...options, type: 'llm-chat', messages: [...messages] });

/** Raw completion of PROMPT. */
export const llmRawCompletion = (
  prompt: string,
  options: Options<'llm-raw-completion', 'prompt'> = {},
): Spec<'llm-raw-completion'> => ({ ...options, type: 'llm-raw-completion', prompt });

/** NLP analysis of INPUT text. */
export const nlpAnalyze = (input: string, options: Options<'nlp-analyze', 'input'> = {}): Spec<'nlp-analyze'> => ({
  ...options,
  type: 'nlp-analyze',
  input,
});

/** Language detection of INPUT text. */
export const nlpDetectLanguage = (input: string): Spec<'nlp-detect-language'> => ({
  type: 'nlp-detect-language',
  input,
});

// ---------------------------------------------------------------------------
// Multimodal message helpers. The deployed chat model is vision-capable, but
// the public /jobs schema currently accepts only plain-string content. These
// build OpenAI-style content-part arrays so callers are ready the moment the
// API accepts them; until then the server rejects such payloads.

export type TextPart = Readonly<{ type: 'text'; text: string }>;
export type ImagePart = Readonly<{ type: 'image_url'; image_url: Readonly<{ url: string }> }>;
export type ContentPart = TextPart | ImagePart;
export type MultimodalMessage = Readonly<{ role: 'user'; content: readonly ContentPart[] }>;

const MIME_TYPES = {
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.webp': 'image/webp',
  '.gif': 'image/gif',
} as const;

const guessMime = (path: string): string | undefined => {
  const lower = path.toLowerCase();
  return Object.entries(MIME_TYPES).find(([ext]) => lower.endsWith(ext))?.[1];
};

const toBase64 = (bytes: Uint8Array): string => {
  let binary = '';
  for (let i = 0; i < bytes.length; i += 0x8000) {
    binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
  }
  return btoa(binary);
};

/** OpenAI-style text content part. */
export const textPart = (text: string): TextPart => ({ type: 'text', text });

/**
 * OpenAI-style image_url content part from SOURCE (a file path or bytes),
 * embedded as a base64 data URL. The MIME type is sniffed from a path's
 * extension (png/jpg/jpeg/webp/gif) or passed explicitly.
 */
export const imagePart = async (source: string | Uint8Array, mimeType?: string): Promise<ImagePart> => {
  const mime = mimeType ?? (typeof source === 'string' ? guessMime(source) : undefined);
  if (!mime) throw configError('Cannot guess image MIME type; pass it explicitly', { source });
  const bytes =
    typeof source === 'string' ? new Uint8Array(await (await import('node:fs/promises')).readFile(source)) : source;
  return { type: 'image_url', image_url: { url: `data:${mime};base64,${toBase64(bytes)}` } };
};

/** A user message whose content is TEXT plus any IMAGE-PARTS (from `imagePart`). */
export const userMessage = (text: string, ...imageParts: readonly ImagePart[]): MultimodalMessage => ({
  role: 'user',
  content: [textPart(text), ...imageParts],
});

// ---------------------------------------------------------------------------

export type RunOptions = Readonly<{
  /** Initial poll interval (default 1000, backing off to an 8 s cap). */
  intervalMs?: number;
  /** Overall deadline (default 120000). */
  timeoutMs?: number;
}>;

const TERMINAL = new Set(['succeeded', 'failed']);

/**
 * Enqueue SPEC, poll until the job reaches a terminal status, then fetch
 * and return the result. Throws a jobFailed error with the failed job, or a
 * timeout error when the deadline passes first.
 */
export const run = async (client: Client, spec: JobSpec, options: RunOptions = {}) => {
  const { job } = await enqueue(client, spec);
  const id = job.id;
  const { job: final } = await http.pollUntil(
    () => get(client, id),
    (r) => TERMINAL.has(r.job.status),
    {
      intervalMs: options.intervalMs,
      timeoutMs: options.timeoutMs ?? 120000,
      message: `Timed out waiting for job ${id}`,
      sleep: client.sleep,
    },
  );
  if (final.status === 'succeeded') return getResult(client, id);
  throw jobFailed(`Job ${id} failed`, final);
};

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/ts.git