File size: 11,665 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
import type { Message, StreamedMessagePart, VideoURLPart } from './message';
import type { Tool } from './tool';
import type { TokenUsage } from './usage';

export type JsonSchemaObject = Record<string, unknown>;

export interface JsonObjectResponseFormat {
  readonly type: 'json_object';
}

export interface JsonSchemaResponseFormat {
  readonly type: 'json_schema';
  readonly jsonSchema: {
    readonly name: string;
    readonly schema: JsonSchemaObject;
    readonly strict?: boolean | undefined;
    readonly description?: string | undefined;
  };
}

export type ResponseFormat = JsonObjectResponseFormat | JsonSchemaResponseFormat;

/**
 * Thinking effort passed to {@link ChatProvider.withThinking}.
 *
 * `'off'` and `'on'` are the only reserved values: `'off'` disables thinking,
 * and `'on'` is the on-signal for boolean models (models that do not declare
 * `support_efforts`). Everything else is a model-declared effort (e.g.
 * `"low"`, `"high"`, `"max"`) carried as an open string. The type collapses to
 * `string` at runtime; it exists purely as a semantic marker that a value is
 * expected to be `'off'`, `'on'`, or a model-declared effort.
 *
 * Provider adapters receive an already-resolved effort. Adapters with a native
 * effort field pass concrete strings through to their upstream API; model
 * compatibility and fallback are resolved before this boundary.
 */
export type ThinkingEffort = 'off' | 'on' | (string & {});

/**
 * Optional context passed to {@link ChatProvider.withMaxCompletionTokens} so a
 * provider can tighten the caller-supplied cap to its own transport
 * constraints.
 */
export interface MaxCompletionTokensOptions {
  /**
   * Tokens already consumed by the current context (API-reported input +
   * output of the latest completed step). Chat-completions providers use it
   * to size the cap to the remaining context window.
   */
  readonly usedContextTokens?: number;
  /** Model context-window size in tokens (`max_context_size`). */
  readonly maxContextTokens?: number;
}

/**
 * Normalized finish-reason signal indicating why a generation stopped.
 *
 * Each provider's native stop value is mapped to one of these, and the
 * unmapped original string is preserved in `rawFinishReason` as an escape
 * hatch. `null` means the provider did not emit a finish_reason (e.g. the
 * stream was cut off before the final event).
 *
 * - `'completed'`: normal completion (OpenAI `'stop'`, Anthropic
 *   `'end_turn'` / `'stop_sequence'`, Gemini `'STOP'`).
 * - `'tool_calls'`: generation paused so the caller can dispatch tool
 *   calls and feed their results back. Note that the OpenAI Responses API
 *   and Google GenAI report `'completed'` here; only the Chat
 *   Completions–style providers and Anthropic surface a dedicated value.
 * - `'truncated'`: token budget exhausted (OpenAI `'length'`, Anthropic
 *   `'max_tokens'`, Gemini `'MAX_TOKENS'`, Responses `'max_output_tokens'`).
 * - `'filtered'`: content filter or safety policy blocked the response.
 * - `'paused'`: Anthropic-specific `'pause_turn'`.
 * - `'other'`: recognized non-null reason that does not fit the categories
 *   above.
 */
export type FinishReason =
  | 'completed'
  | 'tool_calls'
  | 'truncated'
  | 'filtered'
  | 'paused'
  | 'other';

/**
 * An async-iterable stream of message parts produced by a single LLM response.
 *
 * Consumers iterate over the stream with `for await..of` to receive
 * {@link StreamedMessagePart} chunks. After the iteration completes, the
 * {@link id}, {@link usage}, {@link finishReason}, and
 * {@link rawFinishReason} properties reflect the final values reported by
 * the provider.
 */
export interface StreamedMessage {
  [Symbol.asyncIterator](): AsyncIterator<StreamedMessagePart>;
  /** Provider-assigned response identifier, or `null` if not available. */
  readonly id: string | null;
  /** Token usage statistics, populated after the stream completes. */
  readonly usage: TokenUsage | null;
  /**
   * Normalized finish reason, populated after the stream completes.
   *
   * `null` if the provider did not emit a finish_reason (for example, the
   * stream was interrupted before the final event arrived).
   */
  readonly finishReason: FinishReason | null;
  /**
   * Raw provider-specific finish_reason string, preserved verbatim as an
   * escape hatch for callers that need the original wire value.
   *
   * `null` if the provider did not emit a finish_reason.
   */
  readonly rawFinishReason: string | null;
  /**
   * Provider trace identifier read from the `x-trace-id` response header
   * (Kimi/KFC only). Available as soon as the response headers arrive —
   * before the stream body is drained — so hosts can attribute even an
   * interrupted stream to its server-side request. `null` when the provider
   * does not report one.
   */
  readonly traceId?: string | null;
}

/**
 * Options that can be forwarded to a single {@link ChatProvider.generate} call.
 */
export interface ProviderRequestAuth {
  /** Bearer/API token resolved for this specific provider request. */
  apiKey?: string;
  /** Request-scoped headers. These override constructor-level default headers. */
  headers?: Record<string, string>;
}

export interface GenerateOptions {
  /**
   * An {@link AbortSignal} that, when aborted, requests cancellation of the
   * in-flight generate call. Providers that accept a signal will forward it
   * to their underlying HTTP client; the generate loop in
   * {@link generate | generate()} also checks the signal between streamed
   * parts.
   */
  signal?: AbortSignal;
  /**
   * Request-scoped provider auth. Hosts should resolve this immediately before
   * each request/retry so providers never retain mutable credential state.
   */
  auth?: ProviderRequestAuth;
  /**
   * Optional model-output format constraint. Providers map this to their native
   * structured-output field when supported.
   */
  responseFormat?: ResponseFormat;
  /**
   * Host-side instrumentation hook fired immediately before invoking the
   * provider adapter's generate call.
   */
  onRequestStart?: () => void;
  /**
   * Host-side instrumentation hook fired by the provider adapter immediately
   * before it dispatches the network request to the upstream API. The window
   * between {@link onRequestStart} and this hook is in-process request-building
   * time (message serialization, param assembly) spent by the client; the
   * window between this hook and the first streamed part is network + server
   * time. Splitting time-to-first-token across this boundary lets hosts
   * attribute latency to the client vs. the API server.
   */
  onRequestSent?: () => void;
  /**
   * Host-side instrumentation hook fired as soon as the provider response
   * headers arrive (before the stream body is drained), carrying the
   * provider trace identifier from the `x-trace-id` header, or `null` when
   * the provider does not report one. Firing early lets hosts attribute a
   * stream that is cancelled mid-flight to its server-side request.
   */
  onTraceId?: (traceId: string | null) => void;
  /**
   * Host-side instrumentation hook fired after the provider stream is fully
   * drained, before post-processing the assembled response. Receives the
   * {@link StreamDecodeStats} accounting accumulated across the stream when at
   * least one part was streamed, or `undefined` for an empty stream.
   */
  onStreamEnd?: (stats?: StreamDecodeStats) => void;
}

/**
 * Decode-phase accounting for a single streamed generation. Splits the window
 * from the first streamed part to stream end into the time spent waiting on the
 * provider for the next part (server + network) versus the time spent
 * processing each part in-process (deep copy, host callbacks, part merging).
 *
 * Because both buckets are wall-clock measured on the single JS thread, a
 * stop-the-world GC pause that lands while awaiting the next part is counted in
 * {@link serverDecodeMs}; a non-trivial {@link clientConsumeMs} share is the
 * unambiguous signal that the host's per-part processing is throttling decode.
 */
export interface StreamDecodeStats {
  /** Cumulative time spent awaiting the next streamed part (server + network). */
  readonly serverDecodeMs: number;
  /** Cumulative time spent processing streamed parts in-process (client). */
  readonly clientConsumeMs: number;
}

/**
 * In-memory video bytes for providers that require an uploaded file
 * reference instead of an inline data URL.
 */
export interface VideoUploadInput {
  readonly data: Uint8Array;
  readonly mimeType: string;
  readonly filename?: string | undefined;
}

/**
 * Unified interface for an LLM chat provider.
 *
 * Each provider implementation (Kimi, OpenAI, Anthropic, Google GenAI, etc.)
 * converts the common {@link Message} / {@link Tool} types into the
 * provider-specific wire format, streams back a {@link StreamedMessage}, and
 * exposes configuration helpers such as {@link withThinking}.
 */
export interface ChatProvider {
  /** Short identifier for the provider backend (e.g. `"kimi"`, `"anthropic"`). */
  readonly name: string;
  /** Model name passed to the upstream API (e.g. `"moonshot-v1-auto"`). */
  readonly modelName: string;
  /** Current thinking effort, or `null` if thinking is not configured. */
  readonly thinkingEffort: ThinkingEffort | null;
  /**
   * The effective completion-token cap this instance will send on the wire,
   * derived from its generation kwargs — covering constructor defaults (e.g.
   * Anthropic's required `max_tokens`), direct kwargs configuration, and
   * {@link withMaxCompletionTokens} (after any implementation-side clamping:
   * remaining context window, transport ceilings, model-default resolution).
   * `undefined` when the instance sends no cap. Read by hosts that record
   * the outbound request, so the recorded value matches what the provider
   * actually sends.
   */
  readonly maxCompletionTokens?: number;
  /**
   * Send a conversation to the LLM and return a streamed response.
   *
   * @param systemPrompt - System-level instruction prepended to the request.
   * @param tools - Tool definitions the model may invoke.
   * @param history - The conversation history (user, assistant, tool messages).
   * @param options - Optional per-call settings such as an {@link AbortSignal}.
   */
  generate(
    systemPrompt: string,
    tools: Tool[],
    history: Message[],
    options?: GenerateOptions,
  ): Promise<StreamedMessage>;
  /** Return a shallow copy of this provider with the given thinking effort. */
  withThinking(effort: ThinkingEffort): ChatProvider;
  /**
   * Return a shallow copy of this provider with the per-request completion
   * budget clamped to `maxCompletionTokens`. Optional because not every
   * backend benefits from a client-computed cap.
   *
   * When `options` are provided, implementations may further tighten the cap
   * based on their own transport constraints — e.g. chat-completions
   * endpoints size the cap to the remaining context window
   * (`maxContextTokens - usedContextTokens`) and/or clamp to a fixed ceiling.
   *
   * Implementations MUST NOT mutate or replace internal HTTP clients on the
   * returned clone — the clone is expected to share transport state with the
   * original. See `KimiChatProvider._clone()` for the rationale.
   */
  withMaxCompletionTokens?(
    maxCompletionTokens: number,
    options?: MaxCompletionTokensOptions,
  ): ChatProvider;
  /** Upload a video and return a content part that can be sent to this provider. */
  uploadVideo?(input: string | VideoUploadInput, options?: GenerateOptions): Promise<VideoURLPart>;
}