future-lang 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ROADMAP.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Future — Roadmap
2
2
 
3
- **Version:** 0.3.0 · **Last updated:** 2026-06-13
3
+ **Version:** 0.4.1 · **Last updated:** 2026-06-13
4
4
 
5
5
  Status legend: ✅ Done · 🔄 In progress · 📋 Planned · 💡 Idea
6
6
 
@@ -18,7 +18,9 @@ Status legend: ✅ Done · 🔄 In progress · 📋 Planned · 💡 Idea
18
18
  | Multi-line strings | 📋 | Strings must be single-line today |
19
19
  | String `+` concatenation | ✅ | Works via binary `+` operator |
20
20
  | Integer division / modulo | 📋 | `math.trunc(a / b)` workaround for now |
21
- | `use "other.future"` imports | 💡 | No cross-file composition yet |
21
+ | `use "other.future"` imports | | Named + namespace imports; npm packages; recursive deps |
22
+ | `else if` chains | ✅ | One `end` for the whole chain |
23
+ | Reserved namespace protection | ✅ | Compile-time error if reserved name is reassigned |
22
24
  | REPL (`future repl`) | 💡 | Interactive shell with introspection |
23
25
 
24
26
  ---
@@ -28,11 +30,14 @@ Status legend: ✅ Done · 🔄 In progress · 📋 Planned · 💡 Idea
28
30
  | Feature | Status | Notes |
29
31
  |---------|--------|-------|
30
32
  | `ai.ask(prompt)` | ✅ | Single-turn Q&A |
33
+ | `ai.ask(prompt, opts)` | ✅ | `opts`: `temperature`, `max_tokens`, `model`, `system` |
31
34
  | `ai.chat(messages)` | ✅ | Multi-turn conversation |
35
+ | `ai.chat(messages, opts)` | ✅ | Inference options forwarded to provider |
32
36
  | `ai.embed(text)` | ✅ | Real embeddings (OpenAI/Ollama) + keyword fallback |
33
- | `ai.stream(prompt, callback)` | ✅ | Streaming via SSE runtime implemented |
37
+ | `ai.stream(prompt, cb, opts?)` | ✅ | Streaming via SSE; opts forwarded |
34
38
  | `stream ai.ask() ... end` syntax | ✅ | Language-level streaming with implicit `chunk` variable |
35
39
  | `ai.configure(provider, key, model)` | ✅ | Pluggable provider from Future code |
40
+ | Structured `AiError` (status, code, provider) | ✅ | Catchable with rich properties |
36
41
  | Provider: Anthropic | ✅ | Native Messages API |
37
42
  | Provider: OpenAI | ✅ | Via OpenAI-compat layer |
38
43
  | Provider: Ollama | ✅ | Local models, no key needed |
@@ -205,8 +210,11 @@ Status legend: ✅ Done · 🔄 In progress · 📋 Planned · 💡 Idea
205
210
  | `len(x)` built-in | ✅ | Arrays, strings, objects — sync, no runtime needed |
206
211
  | `math.*` module | ✅ | Full JS Math wrapper |
207
212
  | `input(prompt)` built-in | ✅ | stdin (Node.js) / `window.prompt` (browser) |
213
+ | `else if` chains | ✅ | One `end` closes the whole chain |
214
+ | `use "./file.future"` imports | ✅ | Named or namespace imports, recursive deps |
215
+ | `use "npm-pkg" as alias` | ✅ | Import npm packages as namespaces |
216
+ | Reserved namespace protection | ✅ | Compile-time error on redefinition |
208
217
  | Multi-line strings | 📋 | Strings must be single-line |
209
- | `use "other.future"` — module imports | 💡 | No cross-file composition |
210
218
  | REPL (`future repl`) | 💡 | Interactive shell with introspection |
211
219
 
212
220
  ---
@@ -230,21 +238,57 @@ Status legend: ✅ Done · 🔄 In progress · 📋 Planned · 💡 Idea
230
238
 
231
239
  ---
232
240
 
241
+ ## HTTP
242
+
243
+ | Feature | Status | Notes |
244
+ |---------|--------|-------|
245
+ | `http.get(url, headers?)` | ✅ | Parses JSON or returns text |
246
+ | `http.post(url, body, headers?)` | ✅ | JSON body; parses response |
247
+ | `http.configure({ headers, timeout })` | ✅ | Global defaults for all requests |
248
+ | Structured `HttpError` (status, code, url, body) | ✅ | Catchable with rich properties |
249
+ | `http.put / patch / delete` | 📋 | Additional HTTP verbs |
250
+ | Response headers access | 📋 | `res.headers.get("content-type")` |
251
+
252
+ ---
253
+
254
+ ## Testing
255
+
256
+ | Feature | Status | Notes |
257
+ |---------|--------|-------|
258
+ | `future test` command | ✅ | Finds `*.test.future` / `test/**/*.future`, runs all |
259
+ | `future test <pattern>` | ✅ | Filter by filename substring |
260
+ | `assert` namespace | ✅ | `ok`, `equal`, `notEqual`, `deepEqual`, `fail` |
261
+ | Per-file pass/fail reporting | ✅ | `✓` / `✗` with error message |
262
+ | Exit code 1 on failure | ✅ | Integrates with CI |
263
+ | Test isolation (separate process) | 📋 | Currently runs in same Node process |
264
+ | `assert.throws(fn)` | 📋 | Assert that a function throws |
265
+ | Coverage reporting | 💡 | Line coverage for `.future` files |
266
+
267
+ ---
268
+
233
269
  ## Tooling
234
270
 
235
271
  | Feature | Status | Notes |
236
272
  |---------|--------|-------|
237
273
  | CLI: `future run` | ✅ | |
238
274
  | CLI: `future compile` | ✅ | |
239
- | Structured manifest | ✅ | All 12 modules, 50+ functions fully described |
275
+ | CLI: `future compile --sourcemap` | ✅ | Emits Source Map v3 `.js.map` |
276
+ | CLI: `future run --debug` | ✅ | Per-call timing via `FUTURE_DEBUG=1` |
277
+ | CLI: `future test` | ✅ | Test runner for `*.test.future` files |
278
+ | CLI: `future new` | ✅ | Project scaffold |
279
+ | CLI: `future check` | ✅ | Syntax-check without running |
280
+ | CLI: `future fmt` | ✅ | Auto-formatter |
281
+ | CLI: `future doctor` | ✅ | Environment health check |
282
+ | CLI: `future playground` | ✅ | Launches browser playground server |
283
+ | Source maps (`.js.map`) | ✅ | VLQ-encoded Source Map v3 |
284
+ | Structured manifest | ✅ | All 13 modules, 50+ functions fully described |
240
285
  | Runtime introspection API | ✅ | `runtime.describe()` / `listModules()` / `listFunctions()` |
241
286
  | LSP metadata module | ✅ | Completions, hover, signatures |
242
287
  | Browser playground | ✅ | `future-playground.html` — 11 examples |
288
+ | `FUTURE_FOR_LLMS.md` | ✅ | BNF grammar + all APIs for AI code assistants |
289
+ | npm publish (`future-lang`) | ✅ | Public — `npm install -g future-lang` |
243
290
  | VSCode extension | 📋 | Syntax highlighting, completions, hover |
244
291
  | Language Server (LSP) | 📋 | Full editor integration |
245
- | `future fmt` | 📋 | Auto-formatter |
246
- | `future check` | 📋 | Lint / type check without running |
247
- | npm publish (`future-lang`) | 📋 | Public package registry |
248
292
 
249
293
  ---
250
294
 
@@ -252,12 +296,15 @@ Status legend: ✅ Done · 🔄 In progress · 📋 Planned · 💡 Idea
252
296
 
253
297
  | Priority | Item | Why it matters |
254
298
  |----------|------|----------------|
255
- | 🔴 Critical | npm publish (`future-lang`) | Required to ship publicly |
256
299
  | 🔴 Critical | VSCode extension (syntax highlighting) | First impression for new users |
257
- | 🟠 High | `use "other.future"` imports | Cross-file composition for real projects |
258
- | 🟠 High | `system.env(name)` | Read env vars from Future code |
300
+ | 🔴 Critical | Language Server (LSP) | Completions and hover for all IDEs |
301
+ | 🟠 High | `ai.extract(text, schema)` | Structured output is the #1 AI use case |
302
+ | 🟠 High | Test isolation (separate process) | Prevents test state leakage |
303
+ | 🟠 High | `assert.throws(fn)` | Needed for error-handling tests |
259
304
  | 🟡 Medium | Home Assistant REST API | Most HA users don't run MQTT |
260
305
  | 🟡 Medium | Persistent memory / device registry | Most programs are stateless today |
261
306
  | 🟡 Medium | Agent tool-calling loop (ReAct) | True autonomous agents |
307
+ | 🟡 Medium | `http.put / patch / delete` | Needed for full REST APIs |
262
308
  | 🟢 Low | `rag.delete(id)` | Selective document removal |
263
309
  | 🟢 Low | REPL | Nice-to-have for exploration |
310
+ | 🟢 Low | Coverage reporting | `.future` line coverage |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "future-lang",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Future — a small programming language that transpiles to JavaScript, with a capability runtime (HTTP/AI/MQTT/TTS).",
5
5
  "type": "module",
6
6
  "bin": {
package/runtime/ai.js CHANGED
@@ -34,31 +34,76 @@ export function configure(baseUrlOrProvider, apiKey, model) {
34
34
  });
35
35
  }
36
36
 
37
- /** Ask a single question. @returns {Promise<string>} */
38
- export async function ask(prompt) {
39
- return chat([{ role: 'user', content: String(prompt) }]);
37
+ /**
38
+ * Ask a single question.
39
+ * @param {string} prompt
40
+ * @param {{ temperature?: number, max_tokens?: number, model?: string, system?: string }} [opts]
41
+ * @returns {Promise<string>}
42
+ */
43
+ export async function ask(prompt, opts = {}) {
44
+ return chat([{ role: 'user', content: String(prompt) }], opts);
40
45
  }
41
46
 
42
- /** Multi-turn chat. messages = [{ role, content }, ...]. @returns {Promise<string>} */
43
- export async function chat(messages) {
47
+ /**
48
+ * Multi-turn chat.
49
+ * @param {Array<{role,content}>} messages
50
+ * @param {{ temperature?: number, max_tokens?: number, model?: string, system?: string }} [opts]
51
+ * @returns {Promise<string>}
52
+ */
53
+ export async function chat(messages, opts = {}) {
44
54
  const provider = resolveProvider();
45
55
  if (!provider) return offlineStub(messages);
46
- return provider.chat(messages);
56
+ return provider.chat(messages, opts);
47
57
  }
48
58
 
49
59
  /**
50
60
  * Stream a response chunk-by-chunk.
51
61
  * @param {string|Array} promptOrMessages
52
- * @param {(chunk: string) => void} onChunk Called with each text fragment.
62
+ * @param {(chunk: string) => void} onChunk
63
+ * @param {{ temperature?: number, max_tokens?: number, model?: string }} [opts]
53
64
  * @returns {Promise<void>}
54
65
  */
55
- export async function stream(promptOrMessages, onChunk) {
66
+ export async function stream(promptOrMessages, onChunk, opts = {}) {
56
67
  const messages = Array.isArray(promptOrMessages)
57
68
  ? promptOrMessages
58
69
  : [{ role: 'user', content: String(promptOrMessages) }];
59
70
  const provider = resolveProvider();
60
71
  if (!provider) { onChunk(offlineStub(messages)); return; }
61
- return provider.stream(messages, onChunk);
72
+ return provider.stream(messages, onChunk, opts);
73
+ }
74
+
75
+ /**
76
+ * Like ask(), but returns a structured result object instead of a plain string.
77
+ * Includes the generated text, model used, provider name, and token counts.
78
+ *
79
+ * @param {string|Array} prompt String prompt or messages array
80
+ * @param {{ temperature?: number, max_tokens?: number, model?: string, system?: string }} [opts]
81
+ * @returns {Promise<{ text: string, model: string, provider: string, tokens: { input: number, output: number, total: number } }>}
82
+ */
83
+ export async function complete(prompt, opts = {}) {
84
+ const messages = Array.isArray(prompt)
85
+ ? prompt
86
+ : [{ role: 'user', content: String(prompt) }];
87
+ const provider = resolveProvider();
88
+ if (!provider) {
89
+ return {
90
+ text: offlineStub(messages),
91
+ model: 'none',
92
+ provider: 'offline',
93
+ tokens: { input: 0, output: 0, total: 0 },
94
+ };
95
+ }
96
+ if (typeof provider.complete === 'function') {
97
+ return provider.complete(messages, opts);
98
+ }
99
+ // Fallback for providers that don't implement complete() yet.
100
+ const text = await provider.chat(messages, opts);
101
+ return {
102
+ text,
103
+ model: opts.model ?? 'unknown',
104
+ provider: provider.name,
105
+ tokens: { input: 0, output: 0, total: 0 },
106
+ };
62
107
  }
63
108
 
64
109
  /**
@@ -0,0 +1,27 @@
1
+ // runtime/assert.js — Test assertions for `future test`.
2
+ // Wraps node:assert/strict with Future-friendly error messages.
3
+
4
+ import nodeAssert from 'node:assert/strict';
5
+
6
+ function wrap(fn, name) {
7
+ return (...args) => {
8
+ try {
9
+ fn(...args);
10
+ } catch (err) {
11
+ // Re-throw with the assert namespace tag so the test runner can identify it.
12
+ const e = new Error(err.message);
13
+ e.name = 'AssertionError';
14
+ e.namespace = 'assert';
15
+ e.operator = err.operator ?? name;
16
+ e.actual = err.actual;
17
+ e.expected = err.expected;
18
+ throw e;
19
+ }
20
+ };
21
+ }
22
+
23
+ export const ok = wrap((val, msg) => nodeAssert.ok(val, msg), 'ok');
24
+ export const equal = wrap((a, b, msg) => nodeAssert.equal(a, b, msg), 'equal');
25
+ export const notEqual = wrap((a, b, msg) => nodeAssert.notEqual(a, b, msg), 'notEqual');
26
+ export const deepEqual = wrap((a, b, msg) => nodeAssert.deepEqual(a, b, msg), 'deepEqual');
27
+ export const fail = (msg = 'assertion failed') => { throw Object.assign(new Error(msg), { name: 'AssertionError', namespace: 'assert' }); };
package/runtime/http.js CHANGED
@@ -1,32 +1,78 @@
1
1
  // runtime/http.js — consume REST APIs.
2
2
  // Uses the global fetch (stable in Node 22). Returns parsed JSON or text.
3
3
 
4
+ export class HttpError extends Error {
5
+ constructor(status, statusText, url, body) {
6
+ super(`HTTP ${status} ${statusText} — ${url}`);
7
+ this.name = 'HttpError';
8
+ this.status = status;
9
+ this.statusText = statusText;
10
+ this.url = url;
11
+ this.body = body;
12
+ this.namespace = 'http';
13
+ this.code = `HTTP_${status}`;
14
+ }
15
+ }
16
+
17
+ // Global config state — mutated by configure().
18
+ let _config = {
19
+ headers: {},
20
+ timeout: 0,
21
+ };
22
+
23
+ /**
24
+ * Set global defaults for all HTTP requests.
25
+ * Useful for Authorization headers, base timeouts, etc.
26
+ * @param {{ headers?: Record<string,string>, timeout?: number }} opts
27
+ */
28
+ export function configure(opts = {}) {
29
+ if (opts.headers) _config.headers = { ..._config.headers, ...opts.headers };
30
+ if (opts.timeout != null) _config.timeout = opts.timeout;
31
+ }
32
+
4
33
  // Default headers — many public APIs (e.g. GitHub) reject requests without a
5
34
  // User-Agent. Callers can override any of these.
6
35
  const DEFAULT_HEADERS = {
7
- 'user-agent': 'future-lang/0.2 (+https://github.com/future-lang)',
36
+ 'user-agent': 'future-lang/0.4 (+https://github.com/humolot/future-lang)',
8
37
  accept: 'application/json, text/*;q=0.9, */*;q=0.8',
9
38
  };
10
39
 
11
- async function parse(res) {
40
+ async function parseBody(res) {
12
41
  const ct = res.headers.get('content-type') || '';
13
42
  return ct.includes('application/json') ? res.json() : res.text();
14
43
  }
15
44
 
45
+ function buildSignal() {
46
+ if (!_config.timeout) return undefined;
47
+ return AbortSignal.timeout(_config.timeout);
48
+ }
49
+
16
50
  /** GET a URL. @returns parsed JSON object/array, or text. */
17
51
  export async function get(url, headers = {}) {
18
- const res = await fetch(url, { headers: { ...DEFAULT_HEADERS, ...headers } });
19
- if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
20
- return parse(res);
52
+ const res = await fetch(url, {
53
+ headers: { ...DEFAULT_HEADERS, ..._config.headers, ...headers },
54
+ signal: buildSignal(),
55
+ });
56
+ if (!res.ok) {
57
+ let body;
58
+ try { body = await parseBody(res); } catch { body = null; }
59
+ throw new HttpError(res.status, res.statusText, url, body);
60
+ }
61
+ return parseBody(res);
21
62
  }
22
63
 
23
64
  /** POST a JSON body to a URL. @returns parsed JSON or text. */
24
65
  export async function post(url, body, headers = {}) {
25
66
  const res = await fetch(url, {
26
67
  method: 'POST',
27
- headers: { ...DEFAULT_HEADERS, 'content-type': 'application/json', ...headers },
68
+ headers: { ...DEFAULT_HEADERS, 'content-type': 'application/json', ..._config.headers, ...headers },
28
69
  body: typeof body === 'string' ? body : JSON.stringify(body),
70
+ signal: buildSignal(),
29
71
  });
30
- if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText} for ${url}`);
31
- return parse(res);
72
+ if (!res.ok) {
73
+ let errBody;
74
+ try { errBody = await parseBody(res); } catch { errBody = null; }
75
+ throw new HttpError(res.status, res.statusText, url, errBody);
76
+ }
77
+ return parseBody(res);
32
78
  }
package/runtime/index.js CHANGED
@@ -16,15 +16,16 @@ import * as schedule from './schedule.js';
16
16
  import * as system from './system.js';
17
17
  import * as device from './device.js';
18
18
  import * as math from './math.js';
19
+ import * as assert from './assert.js';
19
20
  import readline from 'node:readline';
20
21
 
21
22
  // Canonical ordered list of capability module names.
22
23
  const MODULE_NAMES = [
23
24
  'ai', 'http', 'mqtt', 'tts', 'rag', 'vision', 'home',
24
- 'memory', 'schedule', 'system', 'device', 'math',
25
+ 'memory', 'schedule', 'system', 'device', 'math', 'assert',
25
26
  ];
26
27
 
27
- const _base = { ai, http, mqtt, tts, rag, vision, home, memory, schedule, system, device, math };
28
+ const _base = { ai, http, mqtt, tts, rag, vision, home, memory, schedule, system, device, math, assert };
28
29
 
29
30
  /**
30
31
  * When FUTURE_DEBUG=1, wrap every namespace method with timing/logging.
@@ -82,10 +83,16 @@ export const manifest = {
82
83
  },
83
84
  ask: {
84
85
  description: 'Ask an AI model a question and get a text response',
85
- params: [{ name: 'prompt', type: 'string' }],
86
+ params: [{ name: 'prompt', type: 'string' }, { name: 'opts', type: 'object', optional: true }],
86
87
  returns: 'string',
87
88
  async: true,
88
89
  },
90
+ complete: {
91
+ description: 'Like ask(), but returns a structured object: { text, model, provider, tokens: { input, output, total } }',
92
+ params: [{ name: 'prompt', type: 'string|array' }, { name: 'opts', type: 'object', optional: true }],
93
+ returns: '{ text: string, model: string, provider: string, tokens: { input: number, output: number, total: number } }',
94
+ async: true,
95
+ },
89
96
  chat: {
90
97
  description: 'Send a multi-turn message list to an AI model',
91
98
  params: [{ name: 'messages', type: 'array' }],
@@ -406,6 +413,14 @@ export const manifest = {
406
413
  pi: { description: 'The mathematical constant π', params: [], returns: 'number', async: false },
407
414
  e: { description: "Euler's number", params: [], returns: 'number', async: false },
408
415
  },
416
+
417
+ assert: {
418
+ ok: { description: 'Assert that value is truthy', params: [{ name: 'value', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
419
+ equal: { description: 'Assert that actual === expected', params: [{ name: 'actual', type: 'any' }, { name: 'expected', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
420
+ notEqual: { description: 'Assert that actual !== expected', params: [{ name: 'actual', type: 'any' }, { name: 'expected', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
421
+ deepEqual: { description: 'Assert deep structural equality', params: [{ name: 'actual', type: 'any' }, { name: 'expected', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
422
+ fail: { description: 'Unconditionally fail the test with a message', params: [{ name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
423
+ },
409
424
  };
410
425
 
411
426
  // --- Introspection API ---
@@ -425,7 +440,7 @@ runtime.listFunctions = (mod) => {
425
440
  * Suitable for AI agent discovery or documentation generation.
426
441
  */
427
442
  runtime.describe = () => ({
428
- version: '0.4.0',
443
+ version: '0.4.2',
429
444
  modules: [...MODULE_NAMES],
430
445
  manifest,
431
446
  });
@@ -6,13 +6,26 @@ import { parseSSE, keywordVector } from './util.js';
6
6
 
7
7
  const BASE = 'https://api.anthropic.com/v1';
8
8
 
9
+ export class AiError extends Error {
10
+ constructor(status, provider, body) {
11
+ const msg = body?.error?.message ?? body ?? `HTTP ${status}`;
12
+ super(`[ai:${provider}] ${msg}`);
13
+ this.name = 'AiError';
14
+ this.status = status;
15
+ this.code = `AI_HTTP_${status}`;
16
+ this.namespace = 'ai';
17
+ this.provider = provider;
18
+ this.body = body;
19
+ }
20
+ }
21
+
9
22
  /**
10
23
  * Create an Anthropic provider instance.
11
24
  * @param {{ apiKey: string, model?: string }} config
12
25
  */
13
26
  export function create(config) {
14
- const key = config.apiKey;
15
- const model = config.model ?? 'claude-sonnet-4-6';
27
+ const key = config.apiKey;
28
+ const defaultModel = config.model ?? 'claude-sonnet-4-6';
16
29
 
17
30
  const headers = {
18
31
  'content-type': 'application/json',
@@ -20,28 +33,43 @@ export function create(config) {
20
33
  'anthropic-version': '2023-06-01',
21
34
  };
22
35
 
23
- async function chat(messages) {
36
+ async function chat(messages, opts = {}) {
37
+ const model = opts.model ?? defaultModel;
38
+ const max_tokens = opts.max_tokens ?? 1024;
39
+ const body = { model, max_tokens, messages };
40
+ if (opts.temperature != null) body.temperature = opts.temperature;
41
+ if (opts.system) body.system = opts.system;
42
+
24
43
  const res = await fetch(`${BASE}/messages`, {
25
44
  method: 'POST',
26
45
  headers,
27
- body: JSON.stringify({ model, max_tokens: 1024, messages }),
46
+ body: JSON.stringify(body),
28
47
  });
29
- if (!res.ok) throw new Error(`[anthropic] HTTP ${res.status}: ${await res.text()}`);
48
+ if (!res.ok) {
49
+ let errBody;
50
+ try { errBody = await res.json(); } catch { errBody = await res.text(); }
51
+ throw new AiError(res.status, 'anthropic', errBody);
52
+ }
30
53
  const data = await res.json();
31
54
  return (data.content ?? []).map((b) => b.text ?? '').join('').trim();
32
55
  }
33
56
 
34
- async function ask(prompt) {
35
- return chat([{ role: 'user', content: String(prompt) }]);
57
+ async function ask(prompt, opts = {}) {
58
+ return chat([{ role: 'user', content: String(prompt) }], opts);
36
59
  }
37
60
 
38
- async function stream(messages, onChunk) {
61
+ async function stream(messages, onChunk, opts = {}) {
62
+ const model = opts.model ?? defaultModel;
63
+ const max_tokens = opts.max_tokens ?? 1024;
64
+ const body = { model, max_tokens, messages, stream: true };
65
+ if (opts.temperature != null) body.temperature = opts.temperature;
66
+
39
67
  const res = await fetch(`${BASE}/messages`, {
40
68
  method: 'POST',
41
69
  headers,
42
- body: JSON.stringify({ model, max_tokens: 1024, messages, stream: true }),
70
+ body: JSON.stringify(body),
43
71
  });
44
- if (!res.ok) throw new Error(`[anthropic] stream HTTP ${res.status}`);
72
+ if (!res.ok) throw new AiError(res.status, 'anthropic', `stream HTTP ${res.status}`);
45
73
  for await (const { event, data } of parseSSE(res.body)) {
46
74
  if (event === 'content_block_delta' || data?.type === 'content_block_delta') {
47
75
  onChunk(data.delta?.text ?? '');
@@ -49,11 +77,42 @@ export function create(config) {
49
77
  }
50
78
  }
51
79
 
80
+ async function complete(messages, opts = {}) {
81
+ const model = opts.model ?? defaultModel;
82
+ const max_tokens = opts.max_tokens ?? 1024;
83
+ const body = { model, max_tokens, messages };
84
+ if (opts.temperature != null) body.temperature = opts.temperature;
85
+ if (opts.system) body.system = opts.system;
86
+
87
+ const res = await fetch(`${BASE}/messages`, {
88
+ method: 'POST',
89
+ headers,
90
+ body: JSON.stringify(body),
91
+ });
92
+ if (!res.ok) {
93
+ let errBody;
94
+ try { errBody = await res.json(); } catch { errBody = await res.text(); }
95
+ throw new AiError(res.status, 'anthropic', errBody);
96
+ }
97
+ const data = await res.json();
98
+ const text = (data.content ?? []).map((b) => b.text ?? '').join('').trim();
99
+ return {
100
+ text,
101
+ model: data.model ?? model,
102
+ provider: 'anthropic',
103
+ tokens: {
104
+ input: data.usage?.input_tokens ?? 0,
105
+ output: data.usage?.output_tokens ?? 0,
106
+ total: (data.usage?.input_tokens ?? 0) + (data.usage?.output_tokens ?? 0),
107
+ },
108
+ };
109
+ }
110
+
52
111
  async function embed(text) {
53
112
  // Anthropic has no public embeddings endpoint — use keyword vector fallback.
54
113
  // For production semantic search, configure an OpenAI-compatible provider with an embed model.
55
114
  return keywordVector(String(text));
56
115
  }
57
116
 
58
- return { name: 'anthropic', ask, chat, stream, embed };
117
+ return { name: 'anthropic', ask, chat, complete, stream, embed };
59
118
  }
@@ -7,19 +7,20 @@
7
7
  // No separate SDK or special casing needed.
8
8
 
9
9
  import { parseSSE, keywordVector } from './util.js';
10
+ import { AiError } from './anthropic.js';
10
11
 
11
12
  /**
12
13
  * Well-known provider presets. Used when FUTURE_AI_PROVIDER is set without FUTURE_AI_BASE_URL.
13
14
  * Users can always override by setting FUTURE_AI_BASE_URL directly.
14
15
  */
15
16
  export const PRESETS = {
16
- openai: { baseUrl: 'https://api.openai.com/v1', embedModel: 'text-embedding-3-small' },
17
- ollama: { baseUrl: 'http://localhost:11434/v1', embedModel: 'nomic-embed-text' },
18
- openrouter: { baseUrl: 'https://openrouter.ai/api/v1', embedModel: null },
17
+ openai: { baseUrl: 'https://api.openai.com/v1', embedModel: 'text-embedding-3-small' },
18
+ ollama: { baseUrl: 'http://localhost:11434/v1', embedModel: 'nomic-embed-text' },
19
+ openrouter: { baseUrl: 'https://openrouter.ai/api/v1', embedModel: null },
19
20
  gemini: { baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', embedModel: 'text-embedding-004' },
20
- venice: { baseUrl: 'https://api.venice.ai/api/v1', embedModel: null },
21
- groq: { baseUrl: 'https://api.groq.com/openai/v1', embedModel: null },
22
- together: { baseUrl: 'https://api.together.xyz/v1', embedModel: 'togethercomputer/m2-bert-80M-8k-retrieval' },
21
+ venice: { baseUrl: 'https://api.venice.ai/api/v1', embedModel: null },
22
+ groq: { baseUrl: 'https://api.groq.com/openai/v1', embedModel: null },
23
+ together: { baseUrl: 'https://api.together.xyz/v1', embedModel: 'togethercomputer/m2-bert-80M-8k-retrieval' },
23
24
  };
24
25
 
25
26
  /**
@@ -27,44 +28,89 @@ export const PRESETS = {
27
28
  * @param {{ baseUrl: string, apiKey: string, model?: string, embedModel?: string }} config
28
29
  */
29
30
  export function create(config) {
30
- const baseUrl = config.baseUrl.replace(/\/$/, '');
31
- const apiKey = config.apiKey;
32
- const model = config.model ?? 'gpt-4o-mini';
33
- const embedModel = config.embedModel ?? null;
31
+ const baseUrl = config.baseUrl.replace(/\/$/, '');
32
+ const apiKey = config.apiKey;
33
+ const defaultModel = config.model ?? 'gpt-4o-mini';
34
+ const embedModel = config.embedModel ?? null;
35
+ const providerTag = `openai-compat(${baseUrl})`;
34
36
 
35
37
  const headers = {
36
38
  'content-type': 'application/json',
37
39
  'authorization': `Bearer ${apiKey}`,
38
40
  };
39
41
 
40
- async function chat(messages) {
42
+ async function chat(messages, opts = {}) {
43
+ const model = opts.model ?? defaultModel;
44
+ const max_tokens = opts.max_tokens ?? 1024;
45
+ const body = { model, messages, max_tokens };
46
+ if (opts.temperature != null) body.temperature = opts.temperature;
47
+
41
48
  const res = await fetch(`${baseUrl}/chat/completions`, {
42
49
  method: 'POST',
43
50
  headers,
44
- body: JSON.stringify({ model, messages, max_tokens: 1024 }),
51
+ body: JSON.stringify(body),
45
52
  });
46
- if (!res.ok) throw new Error(`[ai/${baseUrl}] HTTP ${res.status}: ${await res.text()}`);
53
+ if (!res.ok) {
54
+ let errBody;
55
+ try { errBody = await res.json(); } catch { errBody = await res.text(); }
56
+ throw new AiError(res.status, providerTag, errBody);
57
+ }
47
58
  const data = await res.json();
48
59
  return data.choices?.[0]?.message?.content?.trim() ?? '';
49
60
  }
50
61
 
51
- async function ask(prompt) {
52
- return chat([{ role: 'user', content: String(prompt) }]);
62
+ async function ask(prompt, opts = {}) {
63
+ return chat([{ role: 'user', content: String(prompt) }], opts);
53
64
  }
54
65
 
55
- async function stream(messages, onChunk) {
66
+ async function stream(messages, onChunk, opts = {}) {
67
+ const model = opts.model ?? defaultModel;
68
+ const max_tokens = opts.max_tokens ?? 1024;
69
+ const body = { model, messages, max_tokens, stream: true };
70
+ if (opts.temperature != null) body.temperature = opts.temperature;
71
+
56
72
  const res = await fetch(`${baseUrl}/chat/completions`, {
57
73
  method: 'POST',
58
74
  headers,
59
- body: JSON.stringify({ model, messages, max_tokens: 1024, stream: true }),
75
+ body: JSON.stringify(body),
60
76
  });
61
- if (!res.ok) throw new Error(`[ai/${baseUrl}] stream HTTP ${res.status}`);
77
+ if (!res.ok) throw new AiError(res.status, providerTag, `stream HTTP ${res.status}`);
62
78
  for await (const { data } of parseSSE(res.body)) {
63
79
  const chunk = data.choices?.[0]?.delta?.content;
64
80
  if (chunk) onChunk(chunk);
65
81
  }
66
82
  }
67
83
 
84
+ async function complete(messages, opts = {}) {
85
+ const model = opts.model ?? defaultModel;
86
+ const max_tokens = opts.max_tokens ?? 1024;
87
+ const body = { model, messages, max_tokens };
88
+ if (opts.temperature != null) body.temperature = opts.temperature;
89
+
90
+ const res = await fetch(`${baseUrl}/chat/completions`, {
91
+ method: 'POST',
92
+ headers,
93
+ body: JSON.stringify(body),
94
+ });
95
+ if (!res.ok) {
96
+ let errBody;
97
+ try { errBody = await res.json(); } catch { errBody = await res.text(); }
98
+ throw new AiError(res.status, providerTag, errBody);
99
+ }
100
+ const data = await res.json();
101
+ const text = data.choices?.[0]?.message?.content?.trim() ?? '';
102
+ return {
103
+ text,
104
+ model: data.model ?? model,
105
+ provider: providerTag,
106
+ tokens: {
107
+ input: data.usage?.prompt_tokens ?? 0,
108
+ output: data.usage?.completion_tokens ?? 0,
109
+ total: data.usage?.total_tokens ?? 0,
110
+ },
111
+ };
112
+ }
113
+
68
114
  async function embed(text) {
69
115
  if (!embedModel) return keywordVector(String(text));
70
116
  try {
@@ -81,5 +127,5 @@ export function create(config) {
81
127
  }
82
128
  }
83
129
 
84
- return { name: `openai-compat(${baseUrl})`, ask, chat, stream, embed };
130
+ return { name: providerTag, ask, chat, complete, stream, embed };
85
131
  }