@este.systems/dsc 1.1.1 → 1.2.0

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/dist/api.js CHANGED
@@ -1,11 +1,41 @@
1
- export const API_URL = "https://api.deepseek.com/chat/completions";
2
- export const AVAILABLE_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"];
3
- export const DEFAULT_MODEL = "deepseek-v4-pro";
4
- // USD per token. v4-pro figures are discounted (valid through 2026-05-31), copied from godot-assistant.
5
- export const MODEL_RATES = {
6
- "deepseek-v4-pro": { in_hit: 0.0034e-6, in_miss: 0.414e-6, out: 0.828e-6 },
7
- "deepseek-v4-flash": { in_hit: 0.0028e-6, in_miss: 0.138e-6, out: 0.276e-6 },
1
+ export const DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
2
+ // Registry of every model dsc knows. The active model is the routing key: its
3
+ // `provider` selects the transport, the API key, and the cost table. Adding a
4
+ // provider is two steps register its models here, and register the Provider
5
+ // in PROVIDERS below. v4-pro figures are discounted (valid through 2026-05-31).
6
+ export const MODEL_REGISTRY = {
7
+ "deepseek-v4-pro": {
8
+ id: "deepseek-v4-pro",
9
+ provider: "deepseek",
10
+ rates: { in_hit: 0.0034e-6, in_miss: 0.414e-6, out: 0.828e-6 },
11
+ contextWindow: 1_000_000,
12
+ },
13
+ "deepseek-v4-flash": {
14
+ id: "deepseek-v4-flash",
15
+ provider: "deepseek",
16
+ rates: { in_hit: 0.0028e-6, in_miss: 0.138e-6, out: 0.276e-6 },
17
+ contextWindow: 1_000_000,
18
+ },
19
+ // Anthropic. Pricing: $3/M input, $15/M output, $0.30/M cache read.
20
+ // Requires ANTHROPIC_API_KEY (env) or providers.anthropic.api_key (config).
21
+ "claude-sonnet-4-6": {
22
+ id: "claude-sonnet-4-6",
23
+ provider: "anthropic",
24
+ rates: { in_hit: 0.30e-6, in_miss: 3e-6, out: 15e-6 },
25
+ contextWindow: 200_000,
26
+ maxTokens: 8192,
27
+ },
8
28
  };
29
+ export const DEFAULT_MODEL = "deepseek-v4-pro";
30
+ /** Model ids dsc will offer. Phase 1 lists every registered model (all
31
+ * DeepSeek today); provider-key availability filtering arrives with the
32
+ * multi-provider config work. Order is registry insertion order. */
33
+ export const AVAILABLE_MODELS = Object.keys(MODEL_REGISTRY);
34
+ /** Resolve a model id to its spec, falling back to the default for unknown
35
+ * ids (mirrors history.ts's load-time model guard). */
36
+ export function modelSpec(model) {
37
+ return MODEL_REGISTRY[model] ?? MODEL_REGISTRY[DEFAULT_MODEL];
38
+ }
9
39
  export class DeepSeekError extends Error {
10
40
  status;
11
41
  body;
@@ -201,6 +231,88 @@ export function apiKeySource() {
201
231
  return null;
202
232
  }
203
233
  }
234
+ /** Per-provider key metadata for `/api-key` and onboarding messages. */
235
+ export const PROVIDER_KEY_INFO = {
236
+ deepseek: {
237
+ label: "DeepSeek",
238
+ envVar: "DEEPSEEK_API_KEY",
239
+ signup: "https://platform.deepseek.com/api_keys",
240
+ },
241
+ anthropic: {
242
+ label: "Anthropic",
243
+ envVar: "ANTHROPIC_API_KEY",
244
+ signup: "https://console.anthropic.com/settings/keys",
245
+ },
246
+ };
247
+ /** Read a non-DeepSeek provider's key from `providers.<id>.api_key`. */
248
+ function configProviderKey(provider) {
249
+ const cfg = getConfig();
250
+ const providers = cfg && typeof cfg.providers === "object" && cfg.providers
251
+ ? cfg.providers
252
+ : null;
253
+ const p = providers?.[provider];
254
+ const k = p && typeof p === "object" ? p.api_key : undefined;
255
+ return typeof k === "string" && k.length ? k : null;
256
+ }
257
+ /** Where a provider's key comes from, without revealing it. */
258
+ export function providerKeySource(provider) {
259
+ // DeepSeek has the richer legacy reader (env var, top-level api_key, env block).
260
+ if (provider === "deepseek")
261
+ return apiKeySource();
262
+ const info = PROVIDER_KEY_INFO[provider];
263
+ if (info && process.env[info.envVar])
264
+ return "env";
265
+ try {
266
+ return configProviderKey(provider) ? "file" : null;
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ }
272
+ /**
273
+ * Save a provider's API key to the config file, preserving other fields.
274
+ * DeepSeek writes the top-level `api_key` (back-compat with getApiKey);
275
+ * every other provider writes `providers.<id>.api_key`.
276
+ */
277
+ export async function saveProviderKey(provider, key) {
278
+ const trimmed = key.trim();
279
+ if (!trimmed)
280
+ throw new DeepSeekError("api key is empty");
281
+ if (provider === "deepseek")
282
+ return saveApiKey(trimmed);
283
+ const p = configPath();
284
+ await fsp.mkdir(nodePath.dirname(p), { recursive: true });
285
+ let existing = {};
286
+ try {
287
+ let txt = await fsp.readFile(p, "utf8");
288
+ if (txt.charCodeAt(0) === 0xfeff)
289
+ txt = txt.slice(1);
290
+ const parsed = JSON.parse(txt);
291
+ if (parsed && typeof parsed === "object")
292
+ existing = parsed;
293
+ }
294
+ catch {
295
+ // missing or unparseable — start fresh
296
+ }
297
+ const providers = existing.providers && typeof existing.providers === "object"
298
+ ? existing.providers
299
+ : {};
300
+ const sub = providers[provider] && typeof providers[provider] === "object"
301
+ ? providers[provider]
302
+ : {};
303
+ sub.api_key = trimmed;
304
+ providers[provider] = sub;
305
+ existing.providers = providers;
306
+ await fsp.writeFile(p, JSON.stringify(existing, null, 2), { encoding: "utf8", mode: 0o600 });
307
+ try {
308
+ await fsp.chmod(p, 0o600);
309
+ }
310
+ catch {
311
+ // POSIX-only; ignore on Windows
312
+ }
313
+ _cachedConfig = undefined;
314
+ return p;
315
+ }
204
316
  /**
205
317
  * Merge `key` into the config file at `configPath()`, creating the file +
206
318
  * parent directory if needed. Preserves any other fields already in the
@@ -330,8 +442,389 @@ export async function saveSearchKey(provider, key) {
330
442
  _cachedConfig = undefined;
331
443
  return p;
332
444
  }
445
+ function openAICompatProvider(o) {
446
+ return {
447
+ id: o.id,
448
+ resolveKey: o.resolveKey,
449
+ chat: (opts) => openAICompatChat(o.url, o.requireKey(), opts),
450
+ chatStream: (opts) => openAICompatStreamOnce(o.url, o.requireKey(), opts),
451
+ };
452
+ }
453
+ const deepseekProvider = openAICompatProvider({
454
+ id: "deepseek",
455
+ url: DEEPSEEK_API_URL,
456
+ resolveKey: () => {
457
+ try {
458
+ return getApiKey();
459
+ }
460
+ catch {
461
+ return null;
462
+ }
463
+ },
464
+ // getApiKey throws the existing "set DEEPSEEK_API_KEY / config" guidance.
465
+ requireKey: getApiKey,
466
+ });
467
+ // ---------------------------------------------------------------------------
468
+ // Anthropic provider
469
+ //
470
+ // The Messages API differs from the OpenAI shape in several ways, so this
471
+ // provider owns a translation layer in both directions:
472
+ // - system prompt is a top-level field, not a message
473
+ // - content is an array of blocks (text / tool_use / tool_result / thinking)
474
+ // - tool *calls* are assistant `tool_use` blocks; tool *results* go in a
475
+ // following user message as `tool_result` blocks (consecutive tool results
476
+ // coalesce into one user turn)
477
+ // - SSE is event-typed (message_start / content_block_delta / message_delta)
478
+ // - usage splits cached vs. fresh input tokens differently
479
+ // The normalized request in / ChatResponse out matches every other provider,
480
+ // so the agent loop never sees the difference.
481
+ // ---------------------------------------------------------------------------
482
+ export const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages";
483
+ const ANTHROPIC_VERSION = "2023-06-01";
484
+ const ANTHROPIC_DEFAULT_MAX_TOKENS = 8192;
485
+ function anthropicKey() {
486
+ const env = process.env.ANTHROPIC_API_KEY;
487
+ if (env)
488
+ return env;
489
+ return configProviderKey("anthropic");
490
+ }
491
+ function requireAnthropicKey() {
492
+ const k = anthropicKey();
493
+ if (!k) {
494
+ throw new DeepSeekError(`No Anthropic API key. Set ANTHROPIC_API_KEY, or add providers.anthropic.api_key to ${configPath()}.`);
495
+ }
496
+ return k;
497
+ }
498
+ /** Translate the normalized message list into Anthropic's (system, messages)
499
+ * pair. Runs of `tool` messages coalesce into a single user turn of
500
+ * `tool_result` blocks, which must follow the assistant `tool_use` turn. */
501
+ export function toAnthropic(messages) {
502
+ let system;
503
+ const out = [];
504
+ let pendingResults = [];
505
+ const flush = () => {
506
+ if (pendingResults.length) {
507
+ out.push({ role: "user", content: pendingResults });
508
+ pendingResults = [];
509
+ }
510
+ };
511
+ for (const m of messages) {
512
+ if (m.role === "system") {
513
+ const text = typeof m.content === "string" ? m.content : "";
514
+ if (text)
515
+ system = system ? `${system}\n\n${text}` : text;
516
+ continue;
517
+ }
518
+ if (m.role === "tool") {
519
+ pendingResults.push({
520
+ type: "tool_result",
521
+ tool_use_id: m.tool_call_id ?? "",
522
+ content: typeof m.content === "string" ? m.content : "",
523
+ });
524
+ continue;
525
+ }
526
+ flush();
527
+ if (m.role === "user") {
528
+ out.push({ role: "user", content: typeof m.content === "string" ? m.content : "" });
529
+ }
530
+ else {
531
+ const blocks = [];
532
+ if (typeof m.content === "string" && m.content)
533
+ blocks.push({ type: "text", text: m.content });
534
+ for (const tc of m.tool_calls ?? []) {
535
+ let input = {};
536
+ try {
537
+ input = JSON.parse(tc.function.arguments || "{}");
538
+ }
539
+ catch {
540
+ input = {};
541
+ }
542
+ blocks.push({ type: "tool_use", id: tc.id, name: tc.function.name, input });
543
+ }
544
+ // Anthropic rejects empty assistant content; guard (shouldn't happen).
545
+ if (blocks.length === 0)
546
+ blocks.push({ type: "text", text: " " });
547
+ out.push({ role: "assistant", content: blocks });
548
+ }
549
+ }
550
+ flush();
551
+ return { system, messages: out };
552
+ }
553
+ export function toAnthropicTools(tools) {
554
+ if (!tools || !tools.length)
555
+ return undefined;
556
+ return tools.map((t) => ({
557
+ name: t.function.name,
558
+ description: t.function.description,
559
+ input_schema: t.function.parameters,
560
+ }));
561
+ }
562
+ export function mapAnthropicStop(s) {
563
+ if (s === "tool_use")
564
+ return "tool_calls";
565
+ if (s === "end_turn" || s === "stop_sequence")
566
+ return "stop";
567
+ if (s === "max_tokens")
568
+ return "length";
569
+ return s ?? undefined;
570
+ }
571
+ export function anthropicUsage(u) {
572
+ if (!u)
573
+ return undefined;
574
+ const input = u.input_tokens ?? 0;
575
+ const cacheRead = u.cache_read_input_tokens ?? 0;
576
+ const cacheCreate = u.cache_creation_input_tokens ?? 0;
577
+ const output = u.output_tokens ?? 0;
578
+ const prompt = input + cacheRead + cacheCreate;
579
+ return {
580
+ prompt_tokens: prompt,
581
+ completion_tokens: output,
582
+ total_tokens: prompt + output,
583
+ // Map cached reads to "hit"; fresh input + cache writes bill at "miss".
584
+ prompt_cache_hit_tokens: cacheRead,
585
+ prompt_cache_miss_tokens: input + cacheCreate,
586
+ };
587
+ }
588
+ function anthropicRequestBody(opts, spec, stream) {
589
+ const { system, messages } = toAnthropic(opts.messages);
590
+ const body = {
591
+ model: opts.model,
592
+ max_tokens: spec.maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS,
593
+ messages,
594
+ stream,
595
+ };
596
+ if (system)
597
+ body.system = system;
598
+ const tools = toAnthropicTools(opts.tools);
599
+ if (tools)
600
+ body.tools = tools;
601
+ return body;
602
+ }
603
+ function anthropicHeaders(apiKey, stream) {
604
+ const h = {
605
+ "content-type": "application/json",
606
+ "x-api-key": apiKey,
607
+ "anthropic-version": ANTHROPIC_VERSION,
608
+ };
609
+ if (stream)
610
+ h["accept"] = "text/event-stream";
611
+ return h;
612
+ }
613
+ async function anthropicChat(opts, spec) {
614
+ const apiKey = requireAnthropicKey();
615
+ const res = await fetch(ANTHROPIC_API_URL, {
616
+ method: "POST",
617
+ headers: anthropicHeaders(apiKey, false),
618
+ body: JSON.stringify(anthropicRequestBody(opts, spec, false)),
619
+ signal: opts.signal,
620
+ });
621
+ const text = await res.text();
622
+ if (!res.ok)
623
+ throw new DeepSeekError(`HTTP ${res.status}`, res.status, text);
624
+ let data;
625
+ try {
626
+ data = JSON.parse(text);
627
+ }
628
+ catch {
629
+ throw new DeepSeekError(`Invalid JSON response: ${text.slice(0, 200)}`);
630
+ }
631
+ const blocks = Array.isArray(data.content) ? data.content : [];
632
+ let content = "";
633
+ let reasoning = "";
634
+ const toolCalls = [];
635
+ for (const b of blocks) {
636
+ if (b.type === "text")
637
+ content += b.text ?? "";
638
+ else if (b.type === "thinking")
639
+ reasoning += b.thinking ?? "";
640
+ else if (b.type === "tool_use")
641
+ toolCalls.push({
642
+ id: b.id,
643
+ type: "function",
644
+ function: { name: b.name, arguments: JSON.stringify(b.input ?? {}) },
645
+ });
646
+ }
647
+ return {
648
+ choices: [
649
+ {
650
+ message: {
651
+ role: "assistant",
652
+ content: content || null,
653
+ tool_calls: toolCalls.length ? toolCalls : undefined,
654
+ reasoning_content: reasoning || undefined,
655
+ },
656
+ finish_reason: mapAnthropicStop(data.stop_reason),
657
+ },
658
+ ],
659
+ usage: anthropicUsage(data.usage),
660
+ };
661
+ }
662
+ async function anthropicStreamOnce(opts, spec) {
663
+ const apiKey = requireAnthropicKey();
664
+ const res = await fetch(ANTHROPIC_API_URL, {
665
+ method: "POST",
666
+ headers: anthropicHeaders(apiKey, true),
667
+ body: JSON.stringify(anthropicRequestBody(opts, spec, true)),
668
+ signal: opts.signal,
669
+ });
670
+ if (!res.ok) {
671
+ const text = await res.text();
672
+ throw new DeepSeekError(`HTTP ${res.status}`, res.status, text);
673
+ }
674
+ if (!res.body)
675
+ throw new DeepSeekError("No response body for stream");
676
+ let content = "";
677
+ let reasoning = "";
678
+ let stopReason;
679
+ const blocks = {};
680
+ let uInput = 0;
681
+ let uCacheRead = 0;
682
+ let uCacheCreate = 0;
683
+ let uOutput = 0;
684
+ const reader = res.body.getReader();
685
+ const decoder = new TextDecoder("utf-8");
686
+ let buf = "";
687
+ while (true) {
688
+ const { done, value } = await reader.read();
689
+ if (done)
690
+ break;
691
+ buf += decoder.decode(value, { stream: true });
692
+ let nl;
693
+ while ((nl = buf.indexOf("\n")) >= 0) {
694
+ const rawLine = buf.slice(0, nl).replace(/\r$/, "");
695
+ buf = buf.slice(nl + 1);
696
+ // Anthropic sends "event: <type>" + "data: <json>"; the type is also in
697
+ // the JSON payload, so we route on that and ignore the event: lines.
698
+ if (!rawLine || rawLine.startsWith(":") || !rawLine.startsWith("data:"))
699
+ continue;
700
+ const data = rawLine.slice(5).trim();
701
+ let evt;
702
+ try {
703
+ evt = JSON.parse(data);
704
+ }
705
+ catch {
706
+ continue;
707
+ }
708
+ switch (evt.type) {
709
+ case "message_start": {
710
+ const u = evt.message?.usage;
711
+ if (u) {
712
+ uInput = u.input_tokens ?? 0;
713
+ uCacheRead = u.cache_read_input_tokens ?? 0;
714
+ uCacheCreate = u.cache_creation_input_tokens ?? 0;
715
+ }
716
+ break;
717
+ }
718
+ case "content_block_start": {
719
+ const idx = evt.index ?? 0;
720
+ const cb = evt.content_block ?? {};
721
+ blocks[idx] = { type: cb.type, id: cb.id, name: cb.name, args: "" };
722
+ break;
723
+ }
724
+ case "content_block_delta": {
725
+ const idx = evt.index ?? 0;
726
+ const d = evt.delta ?? {};
727
+ if (d.type === "text_delta" && d.text) {
728
+ content += d.text;
729
+ opts.onContent?.(d.text);
730
+ }
731
+ else if (d.type === "thinking_delta" && d.thinking) {
732
+ reasoning += d.thinking;
733
+ opts.onReasoning?.(d.thinking);
734
+ }
735
+ else if (d.type === "input_json_delta" && typeof d.partial_json === "string") {
736
+ if (blocks[idx])
737
+ blocks[idx].args += d.partial_json;
738
+ }
739
+ break;
740
+ }
741
+ case "message_delta": {
742
+ if (evt.delta?.stop_reason)
743
+ stopReason = evt.delta.stop_reason;
744
+ if (evt.usage?.output_tokens != null)
745
+ uOutput = evt.usage.output_tokens;
746
+ break;
747
+ }
748
+ // content_block_stop / message_stop / ping: nothing to accumulate.
749
+ }
750
+ }
751
+ }
752
+ const toolCalls = Object.keys(blocks)
753
+ .map(Number)
754
+ .sort((a, b) => a - b)
755
+ .map((i) => blocks[i])
756
+ .filter((b) => b.type === "tool_use")
757
+ .map((b, i) => ({
758
+ id: b.id ?? `call_${i}`,
759
+ type: "function",
760
+ function: { name: b.name ?? "", arguments: b.args || "{}" },
761
+ }));
762
+ return {
763
+ choices: [
764
+ {
765
+ message: {
766
+ role: "assistant",
767
+ content: content || null,
768
+ tool_calls: toolCalls.length ? toolCalls : undefined,
769
+ reasoning_content: reasoning || undefined,
770
+ },
771
+ finish_reason: mapAnthropicStop(stopReason),
772
+ },
773
+ ],
774
+ usage: anthropicUsage({
775
+ input_tokens: uInput,
776
+ cache_read_input_tokens: uCacheRead,
777
+ cache_creation_input_tokens: uCacheCreate,
778
+ output_tokens: uOutput,
779
+ }),
780
+ };
781
+ }
782
+ const anthropicProvider = {
783
+ id: "anthropic",
784
+ resolveKey: anthropicKey,
785
+ chat: anthropicChat,
786
+ chatStream: anthropicStreamOnce,
787
+ };
788
+ const PROVIDERS = {
789
+ deepseek: deepseekProvider,
790
+ anthropic: anthropicProvider,
791
+ };
792
+ /** Whether a model id is registered (regardless of key availability). Used by
793
+ * the session loader so resuming a session keyed to any known model works. */
794
+ export function isKnownModel(model) {
795
+ return model in MODEL_REGISTRY;
796
+ }
797
+ /** Whether a model can actually be used right now — its provider has a key.
798
+ * The default provider (DeepSeek) is always considered available so the
799
+ * first-launch "set your key" flow still surfaces a model to select. */
800
+ export function modelAvailable(model) {
801
+ const spec = MODEL_REGISTRY[model];
802
+ if (!spec)
803
+ return false;
804
+ if (spec.provider === MODEL_REGISTRY[DEFAULT_MODEL].provider)
805
+ return true;
806
+ const p = PROVIDERS[spec.provider];
807
+ return !!p && p.resolveKey() !== null;
808
+ }
809
+ /** Model ids dsc will offer right now — registered AND usable (provider key
810
+ * present, or the default provider). This is the list `/model` and `--help`
811
+ * should show; `AVAILABLE_MODELS` remains the full registry for validation. */
812
+ export function availableModels() {
813
+ return AVAILABLE_MODELS.filter(modelAvailable);
814
+ }
815
+ /** The Provider that serves a given model, via the registry. */
816
+ export function providerFor(model) {
817
+ const spec = modelSpec(model);
818
+ const p = PROVIDERS[spec.provider];
819
+ if (!p) {
820
+ throw new DeepSeekError(`No provider registered for '${spec.provider}' (model '${model}')`);
821
+ }
822
+ return p;
823
+ }
333
824
  export async function chat(opts) {
334
- const apiKey = getApiKey();
825
+ return providerFor(opts.model).chat(opts, modelSpec(opts.model));
826
+ }
827
+ async function openAICompatChat(url, apiKey, opts) {
335
828
  const body = {
336
829
  model: opts.model,
337
830
  messages: opts.messages,
@@ -339,7 +832,7 @@ export async function chat(opts) {
339
832
  };
340
833
  if (opts.tools && opts.tools.length)
341
834
  body.tools = opts.tools;
342
- const res = await fetch(API_URL, {
835
+ const res = await fetch(url, {
343
836
  method: "POST",
344
837
  headers: {
345
838
  "Content-Type": "application/json",
@@ -392,9 +885,11 @@ function sleep(ms, signal) {
392
885
  });
393
886
  }
394
887
  export async function chatStream(opts) {
888
+ const provider = providerFor(opts.model);
889
+ const spec = modelSpec(opts.model);
395
890
  for (let attempt = 1;; attempt++) {
396
891
  try {
397
- return await streamOnce(opts);
892
+ return await provider.chatStream(opts, spec);
398
893
  }
399
894
  catch (e) {
400
895
  if (isAbortError(e))
@@ -410,8 +905,7 @@ export async function chatStream(opts) {
410
905
  }
411
906
  }
412
907
  }
413
- async function streamOnce(opts) {
414
- const apiKey = getApiKey();
908
+ async function openAICompatStreamOnce(url, apiKey, opts) {
415
909
  const body = {
416
910
  model: opts.model,
417
911
  messages: opts.messages,
@@ -420,7 +914,7 @@ async function streamOnce(opts) {
420
914
  };
421
915
  if (opts.tools && opts.tools.length)
422
916
  body.tools = opts.tools;
423
- const res = await fetch(API_URL, {
917
+ const res = await fetch(url, {
424
918
  method: "POST",
425
919
  headers: {
426
920
  "Content-Type": "application/json",
@@ -551,7 +1045,7 @@ export function recordUsage(stats, usage) {
551
1045
  stats.cache_miss_tokens += usage.prompt_cache_miss_tokens ?? 0;
552
1046
  }
553
1047
  export function computeCostUsd(stats, model) {
554
- const rates = MODEL_RATES[model] ?? MODEL_RATES[DEFAULT_MODEL];
1048
+ const rates = modelSpec(model).rates;
555
1049
  const hit = stats.cache_hit_tokens;
556
1050
  const miss = stats.cache_miss_tokens;
557
1051
  const out = stats.completion_tokens;