noosphere 0.1.2 → 0.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/README.md +2095 -159
- package/dist/index.cjs +226 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +226 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ One import. Every model. Every modality.
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- **4 modalities** — LLM chat, image generation, video generation, and text-to-speech
|
|
10
|
-
- **
|
|
10
|
+
- **Always up-to-date models** — Dynamic auto-fetch from ALL provider APIs at runtime (OpenAI, Anthropic, Google, Groq, Mistral, xAI, Cerebras, OpenRouter)
|
|
11
11
|
- **867+ media endpoints** — via FAL (Flux, SDXL, Kling, Sora 2, VEO 3, Kokoro, ElevenLabs, and hundreds more)
|
|
12
12
|
- **30+ HuggingFace tasks** — LLM, image, TTS, translation, summarization, classification, and more
|
|
13
13
|
- **Local-first architecture** — Auto-detects ComfyUI, Ollama, Piper, and Kokoro on your machine
|
|
@@ -59,6 +59,108 @@ const audio = await ai.speak({
|
|
|
59
59
|
// audio.buffer contains the audio data
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
## Dynamic Model Auto-Fetch — Always Up-to-Date
|
|
63
|
+
|
|
64
|
+
Noosphere **automatically discovers the latest models** from every provider's API at runtime. When Google releases a new Gemini model, when OpenAI drops GPT-5, when Anthropic publishes Claude 4 — **you get them immediately**, without updating Noosphere or any dependency.
|
|
65
|
+
|
|
66
|
+
### The Problem It Solves
|
|
67
|
+
|
|
68
|
+
Traditional AI libraries rely on **static model catalogs** hardcoded at build time. The `@mariozechner/pi-ai` dependency ships with ~246 models in a pre-generated `models.generated.js` file. When a provider releases a new model, you'd have to wait for the library maintainer to run `npm run generate-models`, publish a new version, and then you'd `npm update`. This lag can be days or weeks.
|
|
69
|
+
|
|
70
|
+
### How It Works
|
|
71
|
+
|
|
72
|
+
On the **first API call**, Noosphere queries every provider's model listing API in parallel and merges the results with the static catalog:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
First ai.chat() / ai.image() / ai.stream() call
|
|
76
|
+
│
|
|
77
|
+
├─ 1. Load static pi-ai catalog (246 models with accurate cost/context data)
|
|
78
|
+
│
|
|
79
|
+
├─ 2. Parallel fetch from ALL provider APIs (8 concurrent requests):
|
|
80
|
+
│ ├── GET https://api.openai.com/v1/models (Bearer token)
|
|
81
|
+
│ ├── GET https://api.anthropic.com/v1/models (x-api-key + anthropic-version)
|
|
82
|
+
│ ├── GET https://generativelanguage.googleapis.com/... (API key in URL)
|
|
83
|
+
│ ├── GET https://api.groq.com/openai/v1/models (Bearer token)
|
|
84
|
+
│ ├── GET https://api.mistral.ai/v1/models (Bearer token)
|
|
85
|
+
│ ├── GET https://api.x.ai/v1/models (Bearer token)
|
|
86
|
+
│ ├── GET https://openrouter.ai/api/v1/models (Bearer token)
|
|
87
|
+
│ └── GET https://api.cerebras.ai/v1/models (Bearer token)
|
|
88
|
+
│
|
|
89
|
+
├─ 3. Filter results (chat models only — exclude embeddings, TTS, whisper, etc.)
|
|
90
|
+
│
|
|
91
|
+
├─ 4. Deduplicate against static catalog (static wins — has accurate cost data)
|
|
92
|
+
│
|
|
93
|
+
└─ 5. Merge: Static catalog + newly discovered models = complete model list
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### What Gets Fetched Per Provider
|
|
97
|
+
|
|
98
|
+
| Provider | API Endpoint | Auth | Model Filter | API Protocol |
|
|
99
|
+
|---|---|---|---|---|
|
|
100
|
+
| **OpenAI** | `/v1/models` | Bearer token | `gpt-*`, `o1*`, `o3*`, `o4*`, `chatgpt-*`, `codex-*` | `openai-responses` |
|
|
101
|
+
| **Anthropic** | `/v1/models?limit=100` | `x-api-key` + `anthropic-version` | `claude-*` | `anthropic-messages` |
|
|
102
|
+
| **Google** | `/v1beta/models?key=KEY` | API key in URL | `gemini-*`, `gemma-*` + must support `generateContent` | `google-generative-ai` |
|
|
103
|
+
| **Groq** | `/openai/v1/models` | Bearer token | All (Groq only serves chat models) | `openai-completions` |
|
|
104
|
+
| **Mistral** | `/v1/models` | Bearer token | Exclude `*embed*` | `openai-completions` |
|
|
105
|
+
| **xAI** | `/v1/models` | Bearer token | `grok*` | `openai-completions` |
|
|
106
|
+
| **OpenRouter** | `/api/v1/models` | Bearer token | All (OpenRouter only lists usable models) | `openai-completions` |
|
|
107
|
+
| **Cerebras** | `/v1/models` | Bearer token | All (Cerebras only serves chat models) | `openai-completions` |
|
|
108
|
+
|
|
109
|
+
### Resilience Guarantees
|
|
110
|
+
|
|
111
|
+
- **8-second timeout** per provider — slow APIs don't block everything
|
|
112
|
+
- **`Promise.allSettled()`** — if one provider fails, the others still work
|
|
113
|
+
- **Silent failure** — network errors are caught and ignored, static catalog always available
|
|
114
|
+
- **One-time fetch** — results are cached in memory, not re-fetched on every call
|
|
115
|
+
- **Zero config** — works automatically if you have API keys set
|
|
116
|
+
|
|
117
|
+
### How New Models Become Usable
|
|
118
|
+
|
|
119
|
+
When a dynamically discovered model isn't in the static catalog, Noosphere constructs a **synthetic Model object** that pi-ai's `complete()` and `stream()` functions can use directly:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
// For a new model like "gpt-4.5-turbo" discovered from OpenAI's API:
|
|
123
|
+
{
|
|
124
|
+
id: 'gpt-4.5-turbo',
|
|
125
|
+
name: 'gpt-4.5-turbo',
|
|
126
|
+
api: 'openai-responses', // Correct protocol for the provider
|
|
127
|
+
provider: 'openai',
|
|
128
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
129
|
+
reasoning: false, // Inferred from model ID prefix
|
|
130
|
+
input: ['text', 'image'],
|
|
131
|
+
cost: { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 2.5 }, // From template
|
|
132
|
+
contextWindow: 128000, // From template or provider API
|
|
133
|
+
maxTokens: 16384, // From template or provider API
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Template inheritance:** Cost and context window data come from a "template" — the first model in the static catalog for that provider. This means new models inherit approximate pricing until the static catalog is updated with exact numbers. For Google, the API returns `inputTokenLimit` and `outputTokenLimit` directly, so context window data is always accurate.
|
|
138
|
+
|
|
139
|
+
### Force Refresh
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
const ai = new Noosphere();
|
|
143
|
+
|
|
144
|
+
// Models are auto-fetched on first call:
|
|
145
|
+
await ai.chat({ model: 'gemini-2.5-ultra', messages: [...] }); // works immediately
|
|
146
|
+
|
|
147
|
+
// Force a re-fetch if you suspect new models were added mid-session:
|
|
148
|
+
// (access the provider's refreshDynamicModels method via the registry)
|
|
149
|
+
const models = await ai.getModels('llm');
|
|
150
|
+
// Or trigger a full sync:
|
|
151
|
+
await ai.syncModels();
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Why Not Just Use the Provider APIs Directly?
|
|
155
|
+
|
|
156
|
+
| Approach | Pros | Cons |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| **Static catalog only** (old) | Accurate costs, fast startup | Stale within days, miss new models |
|
|
159
|
+
| **Dynamic only** | Always current | No cost data, no context window info, slow startup |
|
|
160
|
+
| **Hybrid (Noosphere)** | Best of both — accurate data for known models + immediate access to new ones | New models have estimated costs until catalog update |
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
62
164
|
## Configuration
|
|
63
165
|
|
|
64
166
|
API keys are resolved from the constructor config or environment variables (config takes priority):
|
|
@@ -478,68 +580,507 @@ A unified gateway that routes to 8 LLM providers through 4 different API protoco
|
|
|
478
580
|
|
|
479
581
|
Aggregator providing access to hundreds of additional models including Llama, Deepseek, Mistral, Qwen, and many more. Full list available via `ai.getModels('llm')`.
|
|
480
582
|
|
|
481
|
-
####
|
|
583
|
+
#### The Pi-AI Engine — Deep Dive
|
|
584
|
+
|
|
585
|
+
Noosphere's LLM provider is powered by `@mariozechner/pi-ai`, part of the **Pi mono-repo** by Mario Zechner (badlogic). Pi is NOT a wrapper like LangChain or Mastra — it's a **micro-framework for agentic AI** (~15K LOC, 4 npm packages) that was built from scratch as a minimalist alternative to Claude Code.
|
|
586
|
+
|
|
587
|
+
Pi consists of 4 packages in 3 tiers:
|
|
588
|
+
|
|
589
|
+
```
|
|
590
|
+
TIER 1 — FOUNDATION
|
|
591
|
+
@mariozechner/pi-ai LLM API: stream(), complete(), model registry
|
|
592
|
+
0 internal deps, talks to 20+ providers
|
|
593
|
+
|
|
594
|
+
TIER 2 — INFRASTRUCTURE
|
|
595
|
+
@mariozechner/pi-agent-core Agent loop, tool execution, lifecycle events
|
|
596
|
+
Depends on pi-ai
|
|
597
|
+
|
|
598
|
+
@mariozechner/pi-tui Terminal UI with differential rendering
|
|
599
|
+
Standalone, 0 internal deps
|
|
600
|
+
|
|
601
|
+
TIER 3 — APPLICATION
|
|
602
|
+
@mariozechner/pi-coding-agent CLI + SDK: sessions, compaction, extensions
|
|
603
|
+
Depends on all above
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
Noosphere uses `@mariozechner/pi-ai` (Tier 1) directly for LLM access. But the full Pi ecosystem provides capabilities that can be layered on top.
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
#### How Pi Keeps 200+ Models Updated
|
|
611
|
+
|
|
612
|
+
Pi does NOT hardcode models. It has an **auto-generation pipeline** that runs at build time:
|
|
613
|
+
|
|
614
|
+
```
|
|
615
|
+
STEP 1: FETCH (3 sources in parallel)
|
|
616
|
+
┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐
|
|
617
|
+
│ models.dev │ │ OpenRouter │ │ Vercel AI │
|
|
618
|
+
│ /api.json │ │ /v1/models │ │ Gateway │
|
|
619
|
+
│ │ │ │ │ /v1/models │
|
|
620
|
+
│ Context windows │ │ Pricing ($/M) │ │ Capability │
|
|
621
|
+
│ Capabilities │ │ Availability │ │ tags │
|
|
622
|
+
│ Tool support │ │ Provider routing │ │ │
|
|
623
|
+
└────────┬─────────┘ └────────┬─────────┘ └──────┬────────┘
|
|
624
|
+
└─────────┬───────────┴────────────────────┘
|
|
625
|
+
▼
|
|
626
|
+
STEP 2: MERGE & DEDUPLICATE
|
|
627
|
+
Priority: models.dev > OpenRouter > Vercel
|
|
628
|
+
Key: provider + modelId
|
|
629
|
+
│
|
|
630
|
+
▼
|
|
631
|
+
STEP 3: FILTER
|
|
632
|
+
✅ tool_call === true
|
|
633
|
+
✅ streaming supported
|
|
634
|
+
✅ system messages supported
|
|
635
|
+
✅ not deprecated
|
|
636
|
+
│
|
|
637
|
+
▼
|
|
638
|
+
STEP 4: NORMALIZE
|
|
639
|
+
Costs → $/million tokens
|
|
640
|
+
API type → one of 4 protocols
|
|
641
|
+
Input modes → ["text"] or ["text","image"]
|
|
642
|
+
│
|
|
643
|
+
▼
|
|
644
|
+
STEP 5: PATCH (manual corrections)
|
|
645
|
+
Claude Opus: cache pricing fix
|
|
646
|
+
GPT-5.4: context window override
|
|
647
|
+
Kimi K2.5: hardcoded pricing
|
|
648
|
+
│
|
|
649
|
+
▼
|
|
650
|
+
STEP 6: GENERATE TypeScript
|
|
651
|
+
→ models.generated.ts (~330KB)
|
|
652
|
+
→ 200+ models with full type safety
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
Each generated model entry looks like:
|
|
656
|
+
|
|
657
|
+
```typescript
|
|
658
|
+
{
|
|
659
|
+
id: "claude-opus-4-6",
|
|
660
|
+
name: "Claude Opus 4.6",
|
|
661
|
+
api: "anthropic-messages",
|
|
662
|
+
provider: "anthropic",
|
|
663
|
+
baseUrl: "https://api.anthropic.com",
|
|
664
|
+
reasoning: true,
|
|
665
|
+
input: ["text", "image"],
|
|
666
|
+
cost: {
|
|
667
|
+
input: 15, // $15/M tokens
|
|
668
|
+
output: 75, // $75/M tokens
|
|
669
|
+
cacheRead: 1.5, // prompt cache hit
|
|
670
|
+
cacheWrite: 18.75, // prompt cache write
|
|
671
|
+
},
|
|
672
|
+
contextWindow: 200_000,
|
|
673
|
+
maxTokens: 32_000,
|
|
674
|
+
} satisfies Model<"anthropic-messages">
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
When a new model is released (e.g., Gemini 3.0), it appears in models.dev/OpenRouter → the script captures it → a new Pi version is published → Noosphere updates its dependency.
|
|
678
|
+
|
|
679
|
+
---
|
|
680
|
+
|
|
681
|
+
#### 4 API Protocols — How Pi Talks to Every Provider
|
|
682
|
+
|
|
683
|
+
Pi abstracts all LLM providers into 4 wire protocols. Each protocol handles the differences in request format, streaming format, auth headers, and response parsing:
|
|
684
|
+
|
|
685
|
+
| Protocol | Providers | Key Differences |
|
|
686
|
+
|---|---|---|
|
|
687
|
+
| `anthropic-messages` | Anthropic, AWS Bedrock | `system` as top-level field, content as `[{type:"text", text:"..."}]` blocks, `x-api-key` auth, `anthropic-beta` headers |
|
|
688
|
+
| `openai-completions` | OpenAI, xAI, Groq, Cerebras, OpenRouter, Ollama, vLLM | `system` as message with `role:"system"`, content as string, `Authorization: Bearer` auth, `tool_calls` array |
|
|
689
|
+
| `openai-responses` | OpenAI (reasoning models) | New Responses API with server-side context, `store: true`, reasoning summaries |
|
|
690
|
+
| `google-generative-ai` | Google Gemini, Vertex AI | `systemInstruction.parts[{text}]`, role `"model"` instead of `"assistant"`, `functionCall` instead of `tool_calls`, `thinkingConfig` |
|
|
691
|
+
|
|
692
|
+
The core function `streamSimple()` detects which protocol to use based on `model.api` and handles all the formatting/parsing transparently:
|
|
482
693
|
|
|
483
|
-
|
|
694
|
+
```typescript
|
|
695
|
+
// What happens inside Pi when you call Noosphere's chat():
|
|
696
|
+
async function* streamSimple(
|
|
697
|
+
model: Model, // includes model.api to determine protocol
|
|
698
|
+
context: Context, // { systemPrompt, messages, tools }
|
|
699
|
+
options?: StreamOptions // { signal, onPayload, thinkingLevel, ... }
|
|
700
|
+
): AsyncIterable<AssistantMessageEvent> {
|
|
701
|
+
// 1. Format request according to model.api protocol
|
|
702
|
+
// 2. Open SSE/WebSocket stream
|
|
703
|
+
// 3. Parse provider-specific chunks
|
|
704
|
+
// 4. Emit normalized events:
|
|
705
|
+
// → text_delta, thinking_delta, tool_call, message_end
|
|
706
|
+
}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
---
|
|
710
|
+
|
|
711
|
+
#### Agentic Capabilities
|
|
712
|
+
|
|
713
|
+
These are the capabilities people get access to through the Pi-AI engine:
|
|
714
|
+
|
|
715
|
+
##### 1. Tool Use / Function Calling
|
|
716
|
+
|
|
717
|
+
Full structured tool calling supported across **all major providers**. Tool definitions use TypeBox schemas with runtime validation via AJV:
|
|
718
|
+
|
|
719
|
+
```typescript
|
|
720
|
+
import { type Tool, StringEnum } from '@mariozechner/pi-ai';
|
|
721
|
+
import { Type } from '@sinclair/typebox';
|
|
722
|
+
|
|
723
|
+
// Define a tool with typed parameters
|
|
724
|
+
const searchTool: Tool = {
|
|
725
|
+
name: 'web_search',
|
|
726
|
+
description: 'Search the web for information',
|
|
727
|
+
parameters: Type.Object({
|
|
728
|
+
query: Type.String({ description: 'Search query' }),
|
|
729
|
+
maxResults: Type.Optional(Type.Number({ default: 5 })),
|
|
730
|
+
type: StringEnum(['web', 'images', 'news'], { description: 'Search type' }),
|
|
731
|
+
}),
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
// Pass tools in context — Pi handles the rest
|
|
735
|
+
const context = {
|
|
736
|
+
systemPrompt: 'You are a helpful assistant.',
|
|
737
|
+
messages: [{ role: 'user', content: 'Search for recent AI news' }],
|
|
738
|
+
tools: [searchTool],
|
|
739
|
+
};
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
**How tool calling works internally:**
|
|
743
|
+
|
|
744
|
+
```
|
|
745
|
+
User prompt → LLM → "I need to call web_search"
|
|
746
|
+
│
|
|
747
|
+
▼
|
|
748
|
+
Pi validates arguments with AJV
|
|
749
|
+
against the TypeBox schema
|
|
750
|
+
│
|
|
751
|
+
┌─────┴─────┐
|
|
752
|
+
│ Valid? │
|
|
753
|
+
├─Yes───────┤
|
|
754
|
+
│ Execute │
|
|
755
|
+
│ tool │
|
|
756
|
+
├───────────┤
|
|
757
|
+
│ No │
|
|
758
|
+
│ Return │
|
|
759
|
+
│ validation│
|
|
760
|
+
│ error to │
|
|
761
|
+
│ LLM │
|
|
762
|
+
└───────────┘
|
|
763
|
+
│
|
|
764
|
+
▼
|
|
765
|
+
Tool result → back into context → LLM continues
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
**Provider-specific tool_choice control:**
|
|
769
|
+
- **Anthropic:** `"auto" | "any" | "none" | { type: "tool", name: "specific_tool" }`
|
|
770
|
+
- **OpenAI:** `"auto" | "none" | "required" | { type: "function", function: { name: "..." } }`
|
|
771
|
+
- **Google:** `"auto" | "none" | "any"`
|
|
772
|
+
|
|
773
|
+
**Partial JSON streaming:** During streaming, Pi parses tool call arguments incrementally using partial JSON parsing. This means you can see tool arguments being built in real-time, not just after the tool call completes.
|
|
774
|
+
|
|
775
|
+
##### 2. Reasoning / Extended Thinking
|
|
776
|
+
|
|
777
|
+
Pi provides **unified thinking support** across all providers that support it. Thinking blocks are automatically extracted, separated from regular text, and streamed as distinct events:
|
|
778
|
+
|
|
779
|
+
| Provider | Models | Control Parameters | How It Works |
|
|
780
|
+
|---|---|---|---|
|
|
781
|
+
| **Anthropic** | Claude Opus, Sonnet 4+ | `thinkingEnabled: boolean`, `thinkingBudgetTokens: number` | Extended thinking blocks in response, separate `thinking` content type |
|
|
782
|
+
| **OpenAI** | o1, o3, o4, GPT-5 | `reasoningEffort: "minimal" \| "low" \| "medium" \| "high"` | Reasoning via Responses API, `reasoningSummary: "auto" \| "detailed" \| "concise"` |
|
|
783
|
+
| **Google** | Gemini 2.5 Flash/Pro | `thinking.enabled: boolean`, `thinking.budgetTokens: number` | Thinking via `thinkingConfig`, mapped to effort levels |
|
|
784
|
+
| **xAI** | Grok-4, Grok-3-mini | Native reasoning | Automatic when model supports it |
|
|
785
|
+
|
|
786
|
+
**Cross-provider thinking portability:** When switching models mid-conversation, Pi converts thinking blocks between formats. Anthropic thinking blocks become `<thinking>` tagged text when sent to OpenAI/Google, and vice versa.
|
|
484
787
|
|
|
485
|
-
**Tool Use / Function Calling:**
|
|
486
788
|
```typescript
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
789
|
+
// Thinking is automatically extracted in Noosphere responses:
|
|
790
|
+
const result = await ai.chat({
|
|
791
|
+
model: 'claude-opus-4-6',
|
|
792
|
+
messages: [{ role: 'user', content: 'Solve this step by step: 15! / 13!' }],
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
console.log(result.thinking); // "Let me work through this... 15! = 15 × 14 × 13!..."
|
|
796
|
+
console.log(result.content); // "15! / 13! = 15 × 14 = 210"
|
|
797
|
+
|
|
798
|
+
// During streaming, thinking arrives as separate events:
|
|
799
|
+
const stream = ai.stream({ messages: [...] });
|
|
800
|
+
for await (const event of stream) {
|
|
801
|
+
if (event.type === 'thinking_delta') console.log('[THINKING]', event.delta);
|
|
802
|
+
if (event.type === 'text_delta') console.log('[RESPONSE]', event.delta);
|
|
493
803
|
}
|
|
494
804
|
```
|
|
495
805
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
- **Google:** `thinking.enabled`, `thinking.budgetTokens` — Gemini 2.5 thinking
|
|
500
|
-
- **xAI:** Grok-4 native reasoning
|
|
501
|
-
- Thinking blocks are automatically extracted and streamed as separate `thinking_delta` events
|
|
806
|
+
##### 3. Vision / Multimodal Input
|
|
807
|
+
|
|
808
|
+
Models with `input: ["text", "image"]` accept images alongside text. Pi handles the encoding and format differences per provider:
|
|
502
809
|
|
|
503
|
-
**Vision / Multimodal Input:**
|
|
504
810
|
```typescript
|
|
505
|
-
// Send images
|
|
506
|
-
{
|
|
507
|
-
role:
|
|
811
|
+
// Send images to vision-capable models
|
|
812
|
+
const messages = [{
|
|
813
|
+
role: 'user',
|
|
508
814
|
content: [
|
|
509
|
-
{ type:
|
|
510
|
-
{ type:
|
|
511
|
-
]
|
|
512
|
-
}
|
|
815
|
+
{ type: 'text', text: 'What is in this image?' },
|
|
816
|
+
{ type: 'image', data: base64PngString, mimeType: 'image/png' },
|
|
817
|
+
],
|
|
818
|
+
}];
|
|
819
|
+
|
|
820
|
+
// Supported MIME types: image/png, image/jpeg, image/gif, image/webp
|
|
821
|
+
// Images are silently ignored when sent to non-vision models
|
|
513
822
|
```
|
|
514
823
|
|
|
515
|
-
**
|
|
824
|
+
**Vision-capable models include:** All Claude models, all GPT-4o/GPT-5 models, Gemini models, Grok-2-vision, Grok-4, and select Groq models.
|
|
825
|
+
|
|
826
|
+
##### 4. Agent Loop — Autonomous Tool Execution
|
|
827
|
+
|
|
828
|
+
The `@mariozechner/pi-agent-core` package provides a complete agent loop that automatically cycles through `prompt → LLM → tool call → result → repeat` until the task is done:
|
|
829
|
+
|
|
516
830
|
```typescript
|
|
517
|
-
// Built-in agentic execution loop with automatic tool calling
|
|
518
831
|
import { agentLoop } from '@mariozechner/pi-ai';
|
|
519
832
|
|
|
520
|
-
const events = agentLoop(
|
|
521
|
-
|
|
522
|
-
|
|
833
|
+
const events = agentLoop(userMessage, agentContext, {
|
|
834
|
+
model: getModel('anthropic', 'claude-opus-4-6'),
|
|
835
|
+
tools: [searchTool, readFileTool, writeFileTool],
|
|
836
|
+
signal: abortController.signal,
|
|
523
837
|
});
|
|
524
838
|
|
|
525
839
|
for await (const event of events) {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
840
|
+
switch (event.type) {
|
|
841
|
+
case 'agent_start': // Agent begins
|
|
842
|
+
case 'turn_start': // New LLM turn begins
|
|
843
|
+
case 'message_start': // LLM starts responding
|
|
844
|
+
case 'message_update': // Text/thinking delta received
|
|
845
|
+
case 'tool_execution_start': // About to execute a tool
|
|
846
|
+
case 'tool_execution_end': // Tool finished, result available
|
|
847
|
+
case 'message_end': // LLM finished this message
|
|
848
|
+
case 'turn_end': // Turn complete (may loop if tools were called)
|
|
849
|
+
case 'agent_end': // All done, final messages available
|
|
850
|
+
}
|
|
529
851
|
}
|
|
530
852
|
```
|
|
531
853
|
|
|
532
|
-
**
|
|
854
|
+
**The agent loop state machine:**
|
|
855
|
+
|
|
856
|
+
```
|
|
857
|
+
[User sends prompt]
|
|
858
|
+
│
|
|
859
|
+
▼
|
|
860
|
+
┌─[Build Context]──▶ [Check Queues]──▶ [Stream LLM]◄── streamFn()
|
|
861
|
+
│ │
|
|
862
|
+
│ ┌─────┴──────┐
|
|
863
|
+
│ │ │
|
|
864
|
+
│ text tool_call
|
|
865
|
+
│ │ │
|
|
866
|
+
│ ▼ ▼
|
|
867
|
+
│ [Done] [Execute Tool]
|
|
868
|
+
│ │
|
|
869
|
+
│ tool result
|
|
870
|
+
│ │
|
|
871
|
+
└──────────────────────────────────────────────────┘
|
|
872
|
+
(loops back to Stream LLM)
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
**Key design decisions:**
|
|
876
|
+
- Tools execute **sequentially** by default (parallelism can be added on top)
|
|
877
|
+
- The `streamFn` is **injectable** — you can wrap it with middleware to modify requests per-provider
|
|
878
|
+
- Tool arguments are **validated at runtime** using TypeBox + AJV before execution
|
|
879
|
+
- Aborted/failed responses preserve partial content and usage data
|
|
880
|
+
- Tool results are automatically added to the conversation context
|
|
881
|
+
|
|
882
|
+
##### 5. The `streamFn` Pattern — Injectable Middleware
|
|
883
|
+
|
|
884
|
+
This is Pi's most powerful architectural feature. The `streamFn` is the function that actually talks to the LLM, and it can be **wrapped with middleware** like Express.js request handlers:
|
|
885
|
+
|
|
886
|
+
```typescript
|
|
887
|
+
import type { StreamFn } from '@mariozechner/pi-agent-core';
|
|
888
|
+
import { streamSimple } from '@mariozechner/pi-ai';
|
|
889
|
+
|
|
890
|
+
// Start with Pi's base streaming function
|
|
891
|
+
let fn: StreamFn = streamSimple;
|
|
892
|
+
|
|
893
|
+
// Wrap it with middleware that modifies requests per-provider
|
|
894
|
+
fn = createMyCustomWrapper(fn, {
|
|
895
|
+
// Add custom headers for Anthropic
|
|
896
|
+
onPayload: (payload) => {
|
|
897
|
+
if (model.provider === 'anthropic') {
|
|
898
|
+
payload.headers['anthropic-beta'] = 'fine-grained-tool-streaming-2025-05-14';
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// Each wrapper calls the previous one, forming a chain:
|
|
904
|
+
// request → wrapper3 → wrapper2 → wrapper1 → streamSimple → API
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
This pattern is what allows projects like OpenClaw to stack **16 provider-specific wrappers** on top of Pi's base streaming — adding beta headers for Anthropic, WebSocket transport for OpenAI, thinking sanitization for Google, reasoning effort headers for OpenRouter, and more — without modifying Pi's source code.
|
|
908
|
+
|
|
909
|
+
##### 6. Session Management (via pi-coding-agent)
|
|
910
|
+
|
|
911
|
+
The `@mariozechner/pi-coding-agent` package provides persistent session management with JSONL-based storage:
|
|
912
|
+
|
|
913
|
+
```typescript
|
|
914
|
+
import { createAgentSession, SessionManager } from '@mariozechner/pi-coding-agent';
|
|
915
|
+
|
|
916
|
+
// Create a session with full persistence
|
|
917
|
+
const session = await createAgentSession({
|
|
918
|
+
model: 'claude-opus-4-6',
|
|
919
|
+
tools: myTools,
|
|
920
|
+
sessionManager, // handles JSONL persistence
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
const result = await session.run('Build a REST API');
|
|
924
|
+
// Session is automatically saved to:
|
|
925
|
+
// ~/.pi/agent/sessions/session_abc123.jsonl
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
**Session file format (append-only JSONL):**
|
|
929
|
+
```jsonl
|
|
930
|
+
{"role":"user","content":"Build a REST API","timestamp":1710000000}
|
|
931
|
+
{"role":"assistant","content":"I'll create...","model":"claude-opus-4-6","usage":{...}}
|
|
932
|
+
{"role":"toolResult","toolCallId":"tc_001","toolName":"bash","content":"OK"}
|
|
933
|
+
{"type":"compaction","summary":"The user asked to build...","preservedMessages":[...]}
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
**Session operations:**
|
|
937
|
+
- `create()` — new session
|
|
938
|
+
- `open(id)` — restore existing session
|
|
939
|
+
- `continueRecent()` — continue the most recent session
|
|
940
|
+
- `forkFrom(id)` — create a branch (new JSONL referencing parent)
|
|
941
|
+
- `inMemory()` — RAM-only session (for SDK/testing)
|
|
942
|
+
|
|
943
|
+
##### 7. Context Compaction — Automatic Context Window Management
|
|
944
|
+
|
|
945
|
+
When the conversation approaches the model's context window limit, Pi automatically **compacts** the history:
|
|
946
|
+
|
|
947
|
+
```
|
|
948
|
+
1. DETECT: Calculate inputTokens + outputTokens vs model.contextWindow
|
|
949
|
+
2. TRIGGER: Proactively before overflow, or as recovery after overflow error
|
|
950
|
+
3. SUMMARIZE: Send history to LLM with a compaction prompt
|
|
951
|
+
4. WRITE: Append compaction entry to JSONL:
|
|
952
|
+
{"type":"compaction","summary":"...","preservedMessages":[last N messages]}
|
|
953
|
+
5. CONTINUE: Context is now summary + recent messages instead of full history
|
|
954
|
+
```
|
|
955
|
+
|
|
956
|
+
The JSONL file is **never rewritten** — compaction entries are appended, maintaining a complete audit trail.
|
|
957
|
+
|
|
958
|
+
##### 8. Cost Tracking — Cache-Aware Pricing
|
|
959
|
+
|
|
960
|
+
Pi tracks costs per-request with cache-aware pricing for providers that support prompt caching:
|
|
961
|
+
|
|
533
962
|
```typescript
|
|
534
|
-
//
|
|
963
|
+
// Every model has 4 cost dimensions:
|
|
964
|
+
{
|
|
965
|
+
input: 15, // $15 per 1M input tokens
|
|
966
|
+
output: 75, // $75 per 1M output tokens
|
|
967
|
+
cacheRead: 1.5, // $1.50 per 1M cached prompt tokens (read)
|
|
968
|
+
cacheWrite: 18.75, // $18.75 per 1M cached prompt tokens (write)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Usage tracking on every response:
|
|
535
972
|
{
|
|
536
|
-
input:
|
|
537
|
-
output:
|
|
538
|
-
cacheRead:
|
|
539
|
-
cacheWrite:
|
|
973
|
+
input: 1500, // tokens consumed as input
|
|
974
|
+
output: 800, // tokens generated
|
|
975
|
+
cacheRead: 5000, // prompt cache hits
|
|
976
|
+
cacheWrite: 1500, // prompt cache writes
|
|
977
|
+
cost: {
|
|
978
|
+
total: 0.082, // total cost in USD
|
|
979
|
+
input: 0.0225,
|
|
980
|
+
output: 0.06,
|
|
981
|
+
cacheRead: 0.0075,
|
|
982
|
+
cacheWrite: 0.028,
|
|
983
|
+
},
|
|
984
|
+
}
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
**Anthropic and OpenAI** support prompt caching. For providers without caching, `cacheRead` and `cacheWrite` are always 0.
|
|
988
|
+
|
|
989
|
+
##### 9. Extension System (via pi-coding-agent)
|
|
990
|
+
|
|
991
|
+
Pi supports a plugin system where extensions can register tools, commands, and lifecycle hooks:
|
|
992
|
+
|
|
993
|
+
```typescript
|
|
994
|
+
// Extensions are TypeScript modules loaded at runtime via jiti
|
|
995
|
+
export default function(api: ExtensionAPI) {
|
|
996
|
+
// Register a custom tool
|
|
997
|
+
api.registerTool('my_tool', {
|
|
998
|
+
description: 'Does something useful',
|
|
999
|
+
parameters: { /* TypeBox schema */ },
|
|
1000
|
+
execute: async (args) => 'result',
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
// Register a slash command
|
|
1004
|
+
api.registerCommand('/mycommand', {
|
|
1005
|
+
handler: async (args) => { /* ... */ },
|
|
1006
|
+
description: 'Custom command',
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// Hook into the agent lifecycle
|
|
1010
|
+
api.on('before_agent_start', async (context) => {
|
|
1011
|
+
context.systemPrompt += '\nExtra instructions';
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
api.on('tool_execution_end', async (event) => {
|
|
1015
|
+
// Post-process tool results
|
|
1016
|
+
});
|
|
540
1017
|
}
|
|
541
1018
|
```
|
|
542
1019
|
|
|
1020
|
+
**Resource discovery chain (priority):**
|
|
1021
|
+
1. Project `.pi/` directory (highest)
|
|
1022
|
+
2. User `~/.pi/agent/`
|
|
1023
|
+
3. npm packages with Pi metadata
|
|
1024
|
+
4. Built-in defaults
|
|
1025
|
+
|
|
1026
|
+
##### 10. The Anti-MCP Philosophy — Why Pi Uses CLI Instead
|
|
1027
|
+
|
|
1028
|
+
Pi explicitly **rejects MCP** (Model Context Protocol). Mario Zechner's argument, backed by benchmarks:
|
|
1029
|
+
|
|
1030
|
+
**The token cost problem:**
|
|
1031
|
+
|
|
1032
|
+
| Approach | Tools | Tokens Consumed | % of Claude's Context |
|
|
1033
|
+
|---|---|---|---|
|
|
1034
|
+
| Playwright MCP | 21 tools | 13,700 tokens | 6.8% |
|
|
1035
|
+
| Chrome DevTools MCP | 26 tools | 18,000 tokens | 9.0% |
|
|
1036
|
+
| Pi CLI + README | N/A | 225 tokens | ~0.1% |
|
|
1037
|
+
|
|
1038
|
+
That's a **60-80x reduction** in token consumption. With 5 MCP servers, you lose ~55,000 tokens before doing any work.
|
|
1039
|
+
|
|
1040
|
+
**Benchmark results (120 evaluations):**
|
|
1041
|
+
|
|
1042
|
+
| Approach | Avg Cost | Success Rate |
|
|
1043
|
+
|---|---|---|
|
|
1044
|
+
| CLI (tmux) | $0.37 | 100% |
|
|
1045
|
+
| CLI (terminalcp) | $0.39 | 100% |
|
|
1046
|
+
| MCP (terminalcp) | $0.48 | 100% |
|
|
1047
|
+
|
|
1048
|
+
Same success rate, MCP costs **30% more**.
|
|
1049
|
+
|
|
1050
|
+
**Pi's alternative: Progressive Disclosure via CLI tools + READMEs**
|
|
1051
|
+
|
|
1052
|
+
Instead of loading all tool definitions upfront, Pi's agent has `bash` as a built-in tool and discovers CLI tools only when needed:
|
|
1053
|
+
|
|
1054
|
+
```
|
|
1055
|
+
MCP approach: Pi approach:
|
|
1056
|
+
───────────── ──────────
|
|
1057
|
+
Session start → Session start →
|
|
1058
|
+
Load 21 Playwright tools Load 4 tools: read, write, edit, bash
|
|
1059
|
+
Load 26 Chrome DevTools tools (225 tokens)
|
|
1060
|
+
Load N more MCP tools
|
|
1061
|
+
(~55,000 tokens wasted)
|
|
1062
|
+
|
|
1063
|
+
When browser needed: When browser needed:
|
|
1064
|
+
Tools already loaded Agent reads SKILL.md (225 tokens)
|
|
1065
|
+
(but context is polluted) Runs: browser-start.js
|
|
1066
|
+
Runs: browser-nav.js https://...
|
|
1067
|
+
Runs: browser-screenshot.js
|
|
1068
|
+
|
|
1069
|
+
When browser NOT needed: When browser NOT needed:
|
|
1070
|
+
Tools still consume context 0 tokens wasted
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
**The 4 built-in tools** (what Pi argues is sufficient):
|
|
1074
|
+
|
|
1075
|
+
| Tool | What It Does | Why It's Enough |
|
|
1076
|
+
|---|---|---|
|
|
1077
|
+
| `read` | Read files (text + images) | Supports offset/limit for large files |
|
|
1078
|
+
| `write` | Create/overwrite files | Creates directories automatically |
|
|
1079
|
+
| `edit` | Replace text (oldText→newText) | Surgical edits, like a diff |
|
|
1080
|
+
| `bash` | Execute any shell command | **bash can do everything else** — replaces MCP entirely |
|
|
1081
|
+
|
|
1082
|
+
The key insight: `bash` replaces MCP. Any CLI tool, API call, database query, or system operation can be invoked through bash. The agent reads the tool's README only when it needs it, paying tokens on-demand instead of upfront.
|
|
1083
|
+
|
|
543
1084
|
---
|
|
544
1085
|
|
|
545
1086
|
### FAL — Media Generation (867+ endpoints)
|
|
@@ -673,16 +1214,95 @@ The largest media generation provider with dynamic pricing fetched at runtime fr
|
|
|
673
1214
|
**Other TTS:**
|
|
674
1215
|
`fal-ai/f5-tts` (voice cloning), `fal-ai/dia-tts`, `fal-ai/minimax/speech-2.6-turbo`, `fal-ai/minimax/speech-2.6-hd`, `fal-ai/chatterbox/text-to-speech`, `fal-ai/index-tts-2/text-to-speech`
|
|
675
1216
|
|
|
1217
|
+
#### FAL Provider Internals — How It Actually Works
|
|
1218
|
+
|
|
1219
|
+
**Image generation** uses `fal.subscribe()` (queue-based, polls until complete):
|
|
1220
|
+
```typescript
|
|
1221
|
+
// Exact request payload sent to FAL:
|
|
1222
|
+
const response = await fal.subscribe(model, {
|
|
1223
|
+
input: {
|
|
1224
|
+
prompt: "A sunset over mountains",
|
|
1225
|
+
negative_prompt: "blurry", // from options.negativePrompt
|
|
1226
|
+
image_size: { width: 1024, height: 768 }, // from options.width/height
|
|
1227
|
+
seed: 42, // from options.seed
|
|
1228
|
+
num_inference_steps: 30, // from options.steps
|
|
1229
|
+
guidance_scale: 7.5, // from options.guidanceScale
|
|
1230
|
+
},
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// Response parsing — URL from images array:
|
|
1234
|
+
const image = response.data?.images?.[0];
|
|
1235
|
+
// result.url = image?.url
|
|
1236
|
+
// result.media = { width: image?.width, height: image?.height, format: 'png' }
|
|
1237
|
+
```
|
|
1238
|
+
|
|
1239
|
+
**Video generation** uses `fal.subscribe()`:
|
|
1240
|
+
```typescript
|
|
1241
|
+
const response = await fal.subscribe(model, {
|
|
1242
|
+
input: {
|
|
1243
|
+
prompt: "Ocean waves",
|
|
1244
|
+
image_url: "https://...", // from options.imageUrl (image-to-video)
|
|
1245
|
+
duration: 5, // from options.duration
|
|
1246
|
+
fps: 24, // from options.fps
|
|
1247
|
+
},
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
// Response parsing — URL from video object with fallback:
|
|
1251
|
+
const video = response.data?.video;
|
|
1252
|
+
// result.url = video?.url ?? response.data?.video_url
|
|
1253
|
+
// Note: width/height/duration/fps come from INPUT options, not response
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
**TTS** uses `fal.run()` (direct call, NOT subscribe — no queue):
|
|
1257
|
+
```typescript
|
|
1258
|
+
const response = await fal.run(model, {
|
|
1259
|
+
input: {
|
|
1260
|
+
text: "Hello world",
|
|
1261
|
+
voice: "af_heart", // from options.voice
|
|
1262
|
+
speed: 1.0, // from options.speed
|
|
1263
|
+
},
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
// Response parsing — URL from audio object with fallback:
|
|
1267
|
+
// result.url = response.data?.audio_url ?? response.data?.audio?.url
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
**Pricing cache and cost tracking:**
|
|
1271
|
+
```typescript
|
|
1272
|
+
// Pricing fetched dynamically from FAL API during listModels():
|
|
1273
|
+
const res = await fetch('https://api.fal.ai/v1/models/pricing', {
|
|
1274
|
+
headers: { Authorization: `Key ${this.apiKey}` },
|
|
1275
|
+
});
|
|
1276
|
+
// Returns: Array<{ modelId: string, price: number, unit: string }>
|
|
1277
|
+
|
|
1278
|
+
// Cached in memory Map, cleared on each listModels() call:
|
|
1279
|
+
private pricingCache = new Map<string, { price: number; unit: string }>();
|
|
1280
|
+
|
|
1281
|
+
// Cost per request pulled from cache (defaults to 0 if not cached):
|
|
1282
|
+
usage: { cost: pricingCache.get(model)?.price ?? 0 }
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
**Modality inference from model ID — exact string matching:**
|
|
1286
|
+
```typescript
|
|
1287
|
+
inferModality(modelId: string, unit: string): Modality {
|
|
1288
|
+
// TTS: unit contains 'char' OR modelId contains 'tts'/'kokoro'/'elevenlabs'
|
|
1289
|
+
// Video: unit contains 'second' OR modelId contains 'video'/'kling'/'sora'/'veo'
|
|
1290
|
+
// Image: everything else (default)
|
|
1291
|
+
}
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
**Error handling:** Only `listModels()` catches errors (returns `[]`). Image/video/speak methods let FAL errors propagate directly — no wrapping.
|
|
1295
|
+
|
|
676
1296
|
#### FAL Client Capabilities
|
|
677
1297
|
|
|
678
1298
|
The `@fal-ai/client` provides additional features beyond what Noosphere surfaces:
|
|
679
1299
|
|
|
680
|
-
- **Queue API** —
|
|
681
|
-
- **Streaming API** —
|
|
682
|
-
- **Realtime API** — WebSocket connections
|
|
683
|
-
- **Storage API** —
|
|
684
|
-
- **Retry logic** —
|
|
685
|
-
- **Request middleware** —
|
|
1300
|
+
- **Queue API** — `fal.queue.submit()`, `status()`, `result()`, `cancel()`. Supports webhooks, priority levels (`"low"` | `"normal"`), and polling/streaming status modes
|
|
1301
|
+
- **Streaming API** — `fal.streaming.stream()` with async iterators, chunk-level events, configurable timeout between chunks (15s default)
|
|
1302
|
+
- **Realtime API** — `fal.realtime.connect()` for WebSocket connections with msgpack encoding, throttle interval (128ms default), frame buffering (1-60 frames)
|
|
1303
|
+
- **Storage API** — `fal.storage.upload()` with configurable object lifecycle: `"never"` | `"immediate"` | `"1h"` | `"1d"` | `"7d"` | `"30d"` | `"1y"`
|
|
1304
|
+
- **Retry logic** — 3 retries default, exponential backoff (500ms base, 15s max), jitter enabled, retries on 408/429/500/502/503/504
|
|
1305
|
+
- **Request middleware** — `withMiddleware()` for request interceptors, `withProxy()` for proxy configuration
|
|
686
1306
|
|
|
687
1307
|
---
|
|
688
1308
|
|
|
@@ -773,157 +1393,1473 @@ The `@huggingface/inference` library (v3.15.0) provides 30+ AI tasks, including
|
|
|
773
1393
|
- **Multimodal Input:** Images via `image_url` content chunks in chat messages
|
|
774
1394
|
- **17 Inference Providers:** Route through Groq, Together, Fireworks, Replicate, Cerebras, Cohere, and more
|
|
775
1395
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
### ComfyUI — Local Image Generation
|
|
779
|
-
|
|
780
|
-
**Provider ID:** `comfyui`
|
|
781
|
-
**Modalities:** Image, Video (planned)
|
|
782
|
-
**Type:** Local
|
|
783
|
-
**Default Port:** 8188
|
|
784
|
-
|
|
785
|
-
Connects to a local ComfyUI instance for Stable Diffusion workflows.
|
|
1396
|
+
#### HuggingFace Provider Internals — How It Actually Works
|
|
786
1397
|
|
|
787
|
-
|
|
1398
|
+
The `HuggingFaceProvider` class (`src/providers/huggingface.ts`, 141 lines) wraps the `@huggingface/inference` library's `HfInference` client. Here's the exact internal flow for each modality:
|
|
788
1399
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1400
|
+
**Initialization:**
|
|
1401
|
+
```typescript
|
|
1402
|
+
// Constructor receives a single API token
|
|
1403
|
+
constructor(token: string) {
|
|
1404
|
+
this.client = new HfInference(token);
|
|
1405
|
+
// HfInference stores the token internally and attaches it
|
|
1406
|
+
// as Authorization: Bearer <token> to every request
|
|
1407
|
+
}
|
|
795
1408
|
|
|
796
|
-
|
|
1409
|
+
// ping() always returns true — HuggingFace is considered
|
|
1410
|
+
// "available" if the token was provided. No actual HTTP check.
|
|
1411
|
+
async ping(): Promise<boolean> { return true; }
|
|
1412
|
+
```
|
|
797
1413
|
|
|
1414
|
+
**Chat Completions — exact request flow:**
|
|
798
1415
|
```typescript
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1416
|
+
// Default model: meta-llama/Llama-3.1-8B-Instruct
|
|
1417
|
+
const model = options.model ?? 'meta-llama/Llama-3.1-8B-Instruct';
|
|
1418
|
+
|
|
1419
|
+
// Maps directly to HfInference.chatCompletion():
|
|
1420
|
+
const response = await this.client.chatCompletion({
|
|
1421
|
+
model, // HuggingFace model ID or inference endpoint
|
|
1422
|
+
messages: options.messages, // Array<{ role, content }> — passed directly
|
|
1423
|
+
temperature: options.temperature, // 0.0 - 2.0 (optional)
|
|
1424
|
+
max_tokens: options.maxTokens, // Max output tokens (optional)
|
|
807
1425
|
});
|
|
1426
|
+
|
|
1427
|
+
// Response parsing:
|
|
1428
|
+
const choice = response.choices?.[0]; // OpenAI-compatible format
|
|
1429
|
+
const usage = response.usage; // { prompt_tokens, completion_tokens }
|
|
1430
|
+
// result.content = choice?.message?.content ?? ''
|
|
1431
|
+
// result.usage.input = usage?.prompt_tokens
|
|
1432
|
+
// result.usage.output = usage?.completion_tokens
|
|
1433
|
+
// result.usage.cost = 0 (always free for HF Inference API)
|
|
808
1434
|
```
|
|
809
1435
|
|
|
810
|
-
|
|
1436
|
+
**Image Generation — Blob-to-Buffer conversion pipeline:**
|
|
1437
|
+
```typescript
|
|
1438
|
+
// Default model: stabilityai/stable-diffusion-xl-base-1.0
|
|
1439
|
+
const model = options.model ?? 'stabilityai/stable-diffusion-xl-base-1.0';
|
|
1440
|
+
|
|
1441
|
+
// Uses textToImage() which returns a Blob object:
|
|
1442
|
+
const blob = await this.client.textToImage({
|
|
1443
|
+
model,
|
|
1444
|
+
inputs: options.prompt, // The text prompt
|
|
1445
|
+
parameters: {
|
|
1446
|
+
negative_prompt: options.negativePrompt, // What NOT to generate
|
|
1447
|
+
width: options.width, // Pixel width
|
|
1448
|
+
height: options.height, // Pixel height
|
|
1449
|
+
guidance_scale: options.guidanceScale, // CFG scale
|
|
1450
|
+
num_inference_steps: options.steps, // Denoising steps
|
|
1451
|
+
},
|
|
1452
|
+
}, { outputType: 'blob' }); // <-- Forces Blob output (not ReadableStream)
|
|
811
1453
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
- **Default Size:** 1024x1024
|
|
817
|
-
- **Max Size:** 2048x2048
|
|
818
|
-
- **Output:** PNG
|
|
1454
|
+
// Blob → ArrayBuffer → Node.js Buffer conversion:
|
|
1455
|
+
const buffer = Buffer.from(await blob.arrayBuffer());
|
|
1456
|
+
// This is the critical step — HfInference returns a Web API Blob,
|
|
1457
|
+
// which must be converted to a Node.js Buffer for downstream use.
|
|
819
1458
|
|
|
820
|
-
|
|
1459
|
+
// Result always reports PNG format regardless of actual model output:
|
|
1460
|
+
// result.media = { width: options.width ?? 1024, height: options.height ?? 1024, format: 'png' }
|
|
1461
|
+
```
|
|
821
1462
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1463
|
+
**Text-to-Speech — Blob-to-Buffer conversion:**
|
|
1464
|
+
```typescript
|
|
1465
|
+
// Default model: facebook/mms-tts-eng
|
|
1466
|
+
const model = options.model ?? 'facebook/mms-tts-eng';
|
|
1467
|
+
|
|
1468
|
+
// Uses textToSpeech() — simpler API, just model + text:
|
|
1469
|
+
const blob = await this.client.textToSpeech({
|
|
1470
|
+
model,
|
|
1471
|
+
inputs: options.text, // Text to synthesize
|
|
1472
|
+
// Note: No voice, speed, or format parameters — these are model-dependent
|
|
1473
|
+
});
|
|
826
1474
|
|
|
827
|
-
|
|
1475
|
+
// Same Blob → Buffer conversion:
|
|
1476
|
+
const buffer = Buffer.from(await blob.arrayBuffer());
|
|
828
1477
|
|
|
829
|
-
|
|
1478
|
+
// Usage tracks character count, not tokens:
|
|
1479
|
+
// result.usage = { cost: 0, input: options.text.length, unit: 'characters' }
|
|
1480
|
+
// result.media = { format: 'wav' }
|
|
1481
|
+
```
|
|
830
1482
|
|
|
831
|
-
**
|
|
832
|
-
|
|
833
|
-
|
|
1483
|
+
**Model listing — curated defaults, not API discovery:**
|
|
1484
|
+
```typescript
|
|
1485
|
+
// Unlike FAL (which fetches from API) or Pi-AI (which auto-generates),
|
|
1486
|
+
// HuggingFace returns a HARDCODED list of 3 curated models:
|
|
1487
|
+
async listModels(modality?: Modality): Promise<ModelInfo[]> {
|
|
1488
|
+
const models: ModelInfo[] = [];
|
|
1489
|
+
if (!modality || modality === 'image') {
|
|
1490
|
+
models.push({ id: 'stabilityai/stable-diffusion-xl-base-1.0', ... });
|
|
1491
|
+
}
|
|
1492
|
+
if (!modality || modality === 'tts') {
|
|
1493
|
+
models.push({ id: 'facebook/mms-tts-eng', ... });
|
|
1494
|
+
}
|
|
1495
|
+
if (!modality || modality === 'llm') {
|
|
1496
|
+
models.push({ id: 'meta-llama/Llama-3.1-8B-Instruct', ... });
|
|
1497
|
+
}
|
|
1498
|
+
return models;
|
|
1499
|
+
}
|
|
1500
|
+
// This means: the registry only KNOWS about 3 models by default,
|
|
1501
|
+
// but you can use ANY HuggingFace model by passing its ID directly.
|
|
1502
|
+
// The model just won't appear in getModels() or syncModels() results.
|
|
1503
|
+
```
|
|
834
1504
|
|
|
835
|
-
|
|
1505
|
+
#### The 17 HuggingFace Inference Providers
|
|
836
1506
|
|
|
837
|
-
|
|
1507
|
+
The `@huggingface/inference` library supports routing requests through 17 different inference providers. This means a single HuggingFace model ID can be served by multiple backends with different performance/cost characteristics:
|
|
838
1508
|
|
|
839
|
-
|
|
|
1509
|
+
| # | Provider | Type | Strengths |
|
|
840
1510
|
|---|---|---|---|
|
|
841
|
-
|
|
|
842
|
-
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1511
|
+
| 1 | `hf-inference` | HuggingFace's own | Default, free tier, rate-limited |
|
|
1512
|
+
| 2 | `hf-dedicated` | Dedicated endpoints | Private, reserved GPU, guaranteed availability |
|
|
1513
|
+
| 3 | `together-ai` | Together.ai | Fast inference, competitive pricing |
|
|
1514
|
+
| 4 | `fireworks-ai` | Fireworks.ai | Optimized serving, function calling |
|
|
1515
|
+
| 5 | `replicate` | Replicate | Pay-per-use, large model catalog |
|
|
1516
|
+
| 6 | `cerebras` | Cerebras | Extreme speed (WSE-3 hardware) |
|
|
1517
|
+
| 7 | `groq` | Groq | Ultra-low latency (LPU hardware) |
|
|
1518
|
+
| 8 | `cohere` | Cohere | Enterprise, embeddings, RAG |
|
|
1519
|
+
| 9 | `sambanova` | SambaNova | Enterprise RDU hardware |
|
|
1520
|
+
| 10 | `nebius` | Nebius | European cloud infrastructure |
|
|
1521
|
+
| 11 | `hyperbolic` | Hyperbolic Labs | Open-access GPU marketplace |
|
|
1522
|
+
| 12 | `novita` | Novita AI | Cost-efficient inference |
|
|
1523
|
+
| 13 | `ovh-cloud` | OVHcloud | European sovereign cloud |
|
|
1524
|
+
| 14 | `aws` | Amazon SageMaker | AWS-managed endpoints |
|
|
1525
|
+
| 15 | `azure` | Azure ML | Azure-managed endpoints |
|
|
1526
|
+
| 16 | `google-vertex` | Google Vertex | GCP-managed endpoints |
|
|
1527
|
+
| 17 | `deepinfra` | DeepInfra | High-throughput inference |
|
|
1528
|
+
|
|
1529
|
+
**Provider routing** is handled by the `@huggingface/inference` library's internal `provider` parameter:
|
|
1530
|
+
```typescript
|
|
1531
|
+
// Route through a specific inference provider:
|
|
1532
|
+
const response = await client.chatCompletion({
|
|
1533
|
+
model: 'meta-llama/Llama-3.1-70B-Instruct',
|
|
1534
|
+
provider: 'together-ai', // <-- Route through Together.ai
|
|
1535
|
+
messages: [...],
|
|
1536
|
+
});
|
|
847
1537
|
|
|
1538
|
+
// NOTE: Noosphere does NOT currently expose the `provider` parameter
|
|
1539
|
+
// in its ChatOptions type. To use a specific HF inference provider,
|
|
1540
|
+
// you would need a custom provider or direct @huggingface/inference usage.
|
|
848
1541
|
```
|
|
849
|
-
POST /v1/audio/speech
|
|
850
|
-
{
|
|
851
|
-
"model": "tts-1",
|
|
852
|
-
"input": "Hello world",
|
|
853
|
-
"voice": "default",
|
|
854
|
-
"speed": 1.0,
|
|
855
|
-
"response_format": "mp3"
|
|
856
|
-
}
|
|
857
|
-
```
|
|
858
|
-
|
|
859
|
-
Supports `mp3`, `wav`, and `ogg` formats. Returns audio as a Buffer.
|
|
860
1542
|
|
|
861
|
-
|
|
1543
|
+
#### Using HuggingFace Locally — Dedicated Endpoints
|
|
862
1544
|
|
|
863
|
-
|
|
1545
|
+
HuggingFace Inference Endpoints let you deploy any model on dedicated GPUs. The `@huggingface/inference` library supports this via the `endpointUrl` parameter:
|
|
864
1546
|
|
|
865
|
-
|
|
1547
|
+
```typescript
|
|
1548
|
+
// Direct HfInference usage with a local/dedicated endpoint:
|
|
1549
|
+
import { HfInference } from '@huggingface/inference';
|
|
866
1550
|
|
|
867
|
-
|
|
1551
|
+
const client = new HfInference('your-token');
|
|
868
1552
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1553
|
+
// Point to your dedicated endpoint:
|
|
1554
|
+
const response = await client.chatCompletion({
|
|
1555
|
+
model: 'tgi',
|
|
1556
|
+
endpointUrl: 'https://your-endpoint.endpoints.huggingface.cloud',
|
|
1557
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
1558
|
+
});
|
|
872
1559
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
4. Throw NO_PROVIDER error
|
|
1560
|
+
// For a truly local setup with TGI (Text Generation Inference):
|
|
1561
|
+
const localClient = new HfInference(); // No token needed for local
|
|
1562
|
+
const response = await localClient.chatCompletion({
|
|
1563
|
+
model: 'tgi',
|
|
1564
|
+
endpointUrl: 'http://localhost:8080', // Local TGI server
|
|
1565
|
+
messages: [...],
|
|
1566
|
+
});
|
|
881
1567
|
```
|
|
882
1568
|
|
|
883
|
-
|
|
1569
|
+
**Deploying HuggingFace models locally with TGI:**
|
|
884
1570
|
|
|
885
|
-
```
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1571
|
+
```bash
|
|
1572
|
+
# 1. Install Text Generation Inference (TGI):
|
|
1573
|
+
docker run --gpus all -p 8080:80 \
|
|
1574
|
+
-v /data:/data \
|
|
1575
|
+
ghcr.io/huggingface/text-generation-inference:latest \
|
|
1576
|
+
--model-id meta-llama/Llama-3.1-8B-Instruct
|
|
1577
|
+
|
|
1578
|
+
# 2. For image models, use Inference Endpoints:
|
|
1579
|
+
# Deploy via https://ui.endpoints.huggingface.co/
|
|
1580
|
+
# Select your model, GPU type, and region
|
|
1581
|
+
# Get an endpoint URL like: https://xyz123.endpoints.huggingface.cloud
|
|
1582
|
+
|
|
1583
|
+
# 3. For TTS models locally, use the Transformers library:
|
|
1584
|
+
# pip install transformers torch
|
|
1585
|
+
# Then run a local server that serves the model
|
|
896
1586
|
```
|
|
897
1587
|
|
|
898
|
-
**
|
|
1588
|
+
**Other local deployment options:**
|
|
899
1589
|
|
|
900
|
-
|
|
1590
|
+
| Method | URL Pattern | Use Case |
|
|
1591
|
+
|---|---|---|
|
|
1592
|
+
| TGI Docker | `http://localhost:8080` | Production local LLM serving |
|
|
1593
|
+
| HF Inference Endpoints | `https://xxxx.endpoints.huggingface.cloud` | Managed dedicated GPU |
|
|
1594
|
+
| vLLM with HF models | `http://localhost:8000` | High-throughput local serving |
|
|
1595
|
+
| Transformers + FastAPI | Custom URL | Custom model serving |
|
|
901
1596
|
|
|
902
|
-
|
|
1597
|
+
#### Unexposed `@huggingface/inference` Parameters
|
|
903
1598
|
|
|
904
|
-
|
|
905
|
-
- Cache TTL is configurable (default: 60 minutes)
|
|
906
|
-
- `syncModels()` forces a refresh of all provider model lists
|
|
907
|
-
- Registry tracks model → provider mappings for fast resolution
|
|
1599
|
+
The `chatCompletion()` method accepts many parameters that Noosphere's `ChatOptions` doesn't currently expose. These are available if you use the library directly:
|
|
908
1600
|
|
|
909
|
-
|
|
1601
|
+
| Parameter | Type | Description |
|
|
1602
|
+
|---|---|---|
|
|
1603
|
+
| `temperature` | `number` | Sampling temperature (0-2.0) — **exposed** via `ChatOptions.temperature` |
|
|
1604
|
+
| `max_tokens` | `number` | Max output tokens — **exposed** via `ChatOptions.maxTokens` |
|
|
1605
|
+
| `top_p` | `number` | Nucleus sampling threshold (0-1.0) — **not exposed** |
|
|
1606
|
+
| `top_k` | `number` | Top-K sampling — **not exposed** |
|
|
1607
|
+
| `frequency_penalty` | `number` | Penalize repeated tokens (-2.0 to 2.0) — **not exposed** |
|
|
1608
|
+
| `presence_penalty` | `number` | Penalize tokens already present (-2.0 to 2.0) — **not exposed** |
|
|
1609
|
+
| `repetition_penalty` | `number` | Alternative repetition penalty (>1.0 penalizes) — **not exposed** |
|
|
1610
|
+
| `stop` | `string[]` | Stop sequences — **not exposed** |
|
|
1611
|
+
| `seed` | `number` | Deterministic sampling seed — **not exposed** |
|
|
1612
|
+
| `tools` | `Tool[]` | Function/tool definitions — **not exposed** |
|
|
1613
|
+
| `tool_choice` | `string \| object` | Tool selection strategy — **not exposed** |
|
|
1614
|
+
| `tool_prompt` | `string` | System prompt for tool use — **not exposed** |
|
|
1615
|
+
| `response_format` | `object` | JSON schema constraints — **not exposed** |
|
|
1616
|
+
| `reasoning_effort` | `string` | Thinking depth level — **not exposed** |
|
|
1617
|
+
| `stream` | `boolean` | Enable streaming — **not exposed** (use `chatCompletionStream()`) |
|
|
1618
|
+
| `provider` | `string` | Inference provider routing — **not exposed** |
|
|
1619
|
+
| `endpointUrl` | `string` | Custom endpoint URL — **not exposed** |
|
|
1620
|
+
| `n` | `number` | Number of completions — **not exposed** |
|
|
1621
|
+
| `logprobs` | `boolean` | Return log probabilities — **not exposed** |
|
|
1622
|
+
| `grammar` | `object` | BNF grammar constraints — **not exposed** |
|
|
1623
|
+
|
|
1624
|
+
**Image generation unexposed parameters:**
|
|
1625
|
+
| Parameter | Type | Description |
|
|
1626
|
+
|---|---|---|
|
|
1627
|
+
| `negative_prompt` | `string` | **Exposed** via `ImageOptions.negativePrompt` |
|
|
1628
|
+
| `width` / `height` | `number` | **Exposed** via `ImageOptions.width/height` |
|
|
1629
|
+
| `guidance_scale` | `number` | **Exposed** via `ImageOptions.guidanceScale` |
|
|
1630
|
+
| `num_inference_steps` | `number` | **Exposed** via `ImageOptions.steps` |
|
|
1631
|
+
| `scheduler` | `string` | Diffusion scheduler type — **not exposed** |
|
|
1632
|
+
| `target_size` | `object` | Target resize dimensions — **not exposed** |
|
|
1633
|
+
| `clip_skip` | `number` | CLIP skip layers — **not exposed** |
|
|
1634
|
+
|
|
1635
|
+
#### HuggingFace Error Behavior
|
|
1636
|
+
|
|
1637
|
+
Unlike other providers, HuggingFaceProvider does **not** catch errors from the `@huggingface/inference` library. All errors propagate directly up to Noosphere's `executeWithRetry()`:
|
|
1638
|
+
|
|
1639
|
+
```
|
|
1640
|
+
HfInference throws → HuggingFaceProvider propagates →
|
|
1641
|
+
executeWithRetry catches → Noosphere wraps as NoosphereError
|
|
1642
|
+
```
|
|
1643
|
+
|
|
1644
|
+
Common error scenarios:
|
|
1645
|
+
- **401 Unauthorized** — Invalid or expired token → becomes `AUTH_FAILED`
|
|
1646
|
+
- **404 Model Not Found** — Model ID doesn't exist on HF Hub → becomes `MODEL_NOT_FOUND`
|
|
1647
|
+
- **429 Rate Limited** — Free tier limit exceeded → becomes `RATE_LIMITED` (retryable)
|
|
1648
|
+
- **503 Model Loading** — Model is cold-starting on HF Inference → becomes `PROVIDER_UNAVAILABLE` (retryable)
|
|
1649
|
+
|
|
1650
|
+
---
|
|
1651
|
+
|
|
1652
|
+
### ComfyUI — Local Image Generation
|
|
1653
|
+
|
|
1654
|
+
**Provider ID:** `comfyui`
|
|
1655
|
+
**Modalities:** Image, Video (planned)
|
|
1656
|
+
**Type:** Local
|
|
1657
|
+
**Default Port:** 8188
|
|
1658
|
+
**Source:** `src/providers/comfyui.ts` (155 lines)
|
|
1659
|
+
|
|
1660
|
+
Connects to a local ComfyUI instance for Stable Diffusion workflows. ComfyUI is a node-based UI for Stable Diffusion that exposes an HTTP API. Noosphere communicates with it via raw HTTP — no ComfyUI SDK needed.
|
|
1661
|
+
|
|
1662
|
+
#### How It Works — Complete Lifecycle
|
|
1663
|
+
|
|
1664
|
+
```
|
|
1665
|
+
User calls ai.image() →
|
|
1666
|
+
1. structuredClone(DEFAULT_TXT2IMG_WORKFLOW) // Deep-clone the template
|
|
1667
|
+
2. Inject parameters into workflow nodes // Mutate the clone
|
|
1668
|
+
3. POST /prompt { prompt: workflow } // Queue the workflow
|
|
1669
|
+
4. Receive { prompt_id: "abc-123" } // Get tracking ID
|
|
1670
|
+
5. POLL GET /history/abc-123 every 1000ms // Check completion
|
|
1671
|
+
6. Parse outputs → find SaveImage node // Locate generated image
|
|
1672
|
+
7. GET /view?filename=X&subfolder=Y&type=Z // Fetch image binary
|
|
1673
|
+
8. Return Buffer // PNG buffer to caller
|
|
1674
|
+
```
|
|
1675
|
+
|
|
1676
|
+
#### The Complete Workflow JSON — All 8 Nodes
|
|
1677
|
+
|
|
1678
|
+
The `DEFAULT_TXT2IMG_WORKFLOW` constant defines a complete SDXL text-to-image pipeline as a ComfyUI node graph. Each key is a **node ID** (string), each value defines the node type and its connections:
|
|
1679
|
+
|
|
1680
|
+
```typescript
|
|
1681
|
+
// Node "3": KSampler — The core diffusion sampling node
|
|
1682
|
+
'3': {
|
|
1683
|
+
class_type: 'KSampler',
|
|
1684
|
+
inputs: {
|
|
1685
|
+
seed: 0, // Random seed (overridden by options.seed)
|
|
1686
|
+
steps: 20, // Denoising steps (overridden by options.steps)
|
|
1687
|
+
cfg: 7, // CFG/guidance scale (overridden by options.guidanceScale)
|
|
1688
|
+
sampler_name: 'euler', // Sampling algorithm
|
|
1689
|
+
scheduler: 'normal', // Noise schedule
|
|
1690
|
+
denoise: 1, // Full denoise (1.0 = txt2img, <1.0 = img2img)
|
|
1691
|
+
model: ['4', 0], // ← Connection: output 0 of node "4" (checkpoint model)
|
|
1692
|
+
positive: ['6', 0], // ← Connection: output 0 of node "6" (positive prompt)
|
|
1693
|
+
negative: ['7', 0], // ← Connection: output 0 of node "7" (negative prompt)
|
|
1694
|
+
latent_image: ['5', 0], // ← Connection: output 0 of node "5" (empty latent)
|
|
1695
|
+
},
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
// Node "4": CheckpointLoaderSimple — Loads the SDXL model from disk
|
|
1699
|
+
'4': {
|
|
1700
|
+
class_type: 'CheckpointLoaderSimple',
|
|
1701
|
+
inputs: {
|
|
1702
|
+
ckpt_name: 'sd_xl_base_1.0.safetensors', // Checkpoint file on disk
|
|
1703
|
+
// Outputs: [0]=MODEL, [1]=CLIP, [2]=VAE
|
|
1704
|
+
// MODEL → KSampler.model
|
|
1705
|
+
// CLIP → CLIPTextEncode nodes
|
|
1706
|
+
// VAE → VAEDecode
|
|
1707
|
+
},
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
// Node "5": EmptyLatentImage — Creates the initial noise tensor
|
|
1711
|
+
'5': {
|
|
1712
|
+
class_type: 'EmptyLatentImage',
|
|
1713
|
+
inputs: {
|
|
1714
|
+
width: 1024, // Overridden by options.width
|
|
1715
|
+
height: 1024, // Overridden by options.height
|
|
1716
|
+
batch_size: 1, // Always 1 image per generation
|
|
1717
|
+
},
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// Node "6": CLIPTextEncode — Positive prompt encoding
|
|
1721
|
+
'6': {
|
|
1722
|
+
class_type: 'CLIPTextEncode',
|
|
1723
|
+
inputs: {
|
|
1724
|
+
text: '', // Overridden by options.prompt
|
|
1725
|
+
clip: ['4', 1], // ← Connection: output 1 of node "4" (CLIP model)
|
|
1726
|
+
},
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// Node "7": CLIPTextEncode — Negative prompt encoding
|
|
1730
|
+
'7': {
|
|
1731
|
+
class_type: 'CLIPTextEncode',
|
|
1732
|
+
inputs: {
|
|
1733
|
+
text: '', // Overridden by options.negativePrompt ?? ''
|
|
1734
|
+
clip: ['4', 1], // ← Same CLIP model as positive prompt
|
|
1735
|
+
},
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// Node "8": VAEDecode — Converts latent space to pixel space
|
|
1739
|
+
'8': {
|
|
1740
|
+
class_type: 'VAEDecode',
|
|
1741
|
+
inputs: {
|
|
1742
|
+
samples: ['3', 0], // ← Connection: output 0 of node "3" (sampled latents)
|
|
1743
|
+
vae: ['4', 2], // ← Connection: output 2 of node "4" (VAE decoder)
|
|
1744
|
+
},
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Node "9": SaveImage — Saves the final image
|
|
1748
|
+
'9': {
|
|
1749
|
+
class_type: 'SaveImage',
|
|
1750
|
+
inputs: {
|
|
1751
|
+
filename_prefix: 'noosphere', // Files saved as noosphere_00001.png, etc.
|
|
1752
|
+
images: ['8', 0], // ← Connection: output 0 of node "8" (decoded image)
|
|
1753
|
+
},
|
|
1754
|
+
}
|
|
1755
|
+
```
|
|
1756
|
+
|
|
1757
|
+
**Node connection format:** `['nodeId', outputIndex]` — this is ComfyUI's internal linking system. For example, `['4', 1]` means "output slot 1 of node 4", which is the CLIP model from CheckpointLoaderSimple.
|
|
1758
|
+
|
|
1759
|
+
**Visual pipeline flow:**
|
|
1760
|
+
```
|
|
1761
|
+
CheckpointLoader["4"] ──MODEL──→ KSampler["3"]
|
|
1762
|
+
├──CLIP──→ CLIPTextEncode["6"] (positive) ──→ KSampler["3"]
|
|
1763
|
+
├──CLIP──→ CLIPTextEncode["7"] (negative) ──→ KSampler["3"]
|
|
1764
|
+
└──VAE───→ VAEDecode["8"]
|
|
1765
|
+
EmptyLatentImage["5"] ──→ KSampler["3"] ──→ VAEDecode["8"] ──→ SaveImage["9"]
|
|
1766
|
+
```
|
|
1767
|
+
|
|
1768
|
+
#### Parameter Injection — How Options Map to Nodes
|
|
1769
|
+
|
|
1770
|
+
```typescript
|
|
1771
|
+
// Deep-clone to avoid mutating the template:
|
|
1772
|
+
const workflow = structuredClone(DEFAULT_TXT2IMG_WORKFLOW);
|
|
1773
|
+
|
|
1774
|
+
// Direct node mutations:
|
|
1775
|
+
workflow['6'].inputs.text = options.prompt; // Positive prompt → Node 6
|
|
1776
|
+
workflow['7'].inputs.text = options.negativePrompt ?? ''; // Negative prompt → Node 7
|
|
1777
|
+
workflow['5'].inputs.width = options.width ?? 1024; // Width → Node 5
|
|
1778
|
+
workflow['5'].inputs.height = options.height ?? 1024; // Height → Node 5
|
|
1779
|
+
|
|
1780
|
+
// Conditional overrides (only if user provided them):
|
|
1781
|
+
if (options.seed !== undefined) workflow['3'].inputs.seed = options.seed;
|
|
1782
|
+
if (options.steps !== undefined) workflow['3'].inputs.steps = options.steps;
|
|
1783
|
+
if (options.guidanceScale !== undefined) workflow['3'].inputs.cfg = options.guidanceScale;
|
|
1784
|
+
// Note: sampler_name, scheduler, and denoise are NOT configurable via Noosphere.
|
|
1785
|
+
// They're hardcoded to euler/normal/1.0
|
|
1786
|
+
```
|
|
1787
|
+
|
|
1788
|
+
#### Queue Submission — POST /prompt
|
|
1789
|
+
|
|
1790
|
+
```typescript
|
|
1791
|
+
const queueRes = await fetch(`${this.baseUrl}/prompt`, {
|
|
1792
|
+
method: 'POST',
|
|
1793
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1794
|
+
body: JSON.stringify({ prompt: workflow }),
|
|
1795
|
+
// ComfyUI expects: { prompt: <workflow_object>, client_id?: string }
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
if (!queueRes.ok) throw new Error(`ComfyUI queue failed: ${queueRes.status}`);
|
|
1799
|
+
|
|
1800
|
+
const { prompt_id } = await queueRes.json();
|
|
1801
|
+
// prompt_id is a UUID like "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
1802
|
+
// Used to track this specific generation in the history API
|
|
1803
|
+
```
|
|
1804
|
+
|
|
1805
|
+
#### Polling Mechanism — Deadline-Based with 1s Intervals
|
|
1806
|
+
|
|
1807
|
+
```typescript
|
|
1808
|
+
private async pollForResult(promptId: string, maxWaitMs = 300000): Promise<ArrayBuffer> {
|
|
1809
|
+
const deadline = Date.now() + maxWaitMs; // 300,000ms = 5 minutes
|
|
1810
|
+
|
|
1811
|
+
while (Date.now() < deadline) {
|
|
1812
|
+
// Check history for our prompt
|
|
1813
|
+
const res = await fetch(`${this.baseUrl}/history/${promptId}`);
|
|
1814
|
+
|
|
1815
|
+
if (!res.ok) {
|
|
1816
|
+
await new Promise((r) => setTimeout(r, 1000)); // 1 second between polls
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
const history = await res.json();
|
|
1821
|
+
// History format: { [promptId]: { outputs: { [nodeId]: { images: [...] } } } }
|
|
1822
|
+
|
|
1823
|
+
const entry = history[promptId];
|
|
1824
|
+
if (!entry?.outputs) {
|
|
1825
|
+
await new Promise((r) => setTimeout(r, 1000)); // Not ready yet
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// Search ALL output nodes for images (not just node "9"):
|
|
1830
|
+
for (const nodeOutput of Object.values(entry.outputs)) {
|
|
1831
|
+
if (nodeOutput.images?.length > 0) {
|
|
1832
|
+
const img = nodeOutput.images[0];
|
|
1833
|
+
// Fetch the actual image binary:
|
|
1834
|
+
const imgRes = await fetch(
|
|
1835
|
+
`${this.baseUrl}/view?filename=${img.filename}&subfolder=${img.subfolder}&type=${img.type}`
|
|
1836
|
+
);
|
|
1837
|
+
return imgRes.arrayBuffer();
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
throw new Error(`ComfyUI generation timed out after ${maxWaitMs}ms`);
|
|
1845
|
+
}
|
|
1846
|
+
```
|
|
1847
|
+
|
|
1848
|
+
**Key polling details:**
|
|
1849
|
+
- **Interval:** Fixed 1000ms (not configurable)
|
|
1850
|
+
- **Timeout:** 300,000ms = 5 minutes (hardcoded, not from `config.timeout.image`)
|
|
1851
|
+
- **Deadline-based:** Uses `Date.now() < deadline` comparison, NOT a retry counter
|
|
1852
|
+
- **Image fetch URL format:** `/view?filename=noosphere_00001_.png&subfolder=&type=output`
|
|
1853
|
+
- **Returns:** Raw `ArrayBuffer` → converted to `Buffer` by the caller
|
|
1854
|
+
|
|
1855
|
+
#### Auto-Detection — How ComfyUI Gets Discovered
|
|
1856
|
+
|
|
1857
|
+
During `Noosphere.init()`, if `autoDetectLocal` is true:
|
|
1858
|
+
|
|
1859
|
+
```typescript
|
|
1860
|
+
// Ping the /system_stats endpoint with a 2-second timeout:
|
|
1861
|
+
const pingUrl = async (url: string): Promise<boolean> => {
|
|
1862
|
+
const controller = new AbortController();
|
|
1863
|
+
const timer = setTimeout(() => controller.abort(), 2000); // 2s hard timeout
|
|
1864
|
+
try {
|
|
1865
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
1866
|
+
return res.ok;
|
|
1867
|
+
} finally {
|
|
1868
|
+
clearTimeout(timer);
|
|
1869
|
+
}
|
|
1870
|
+
};
|
|
1871
|
+
|
|
1872
|
+
// Check ComfyUI specifically:
|
|
1873
|
+
if (comfyuiCfg?.enabled) {
|
|
1874
|
+
const ok = await pingUrl(`${comfyuiCfg.host}:${comfyuiCfg.port}/system_stats`);
|
|
1875
|
+
if (ok) {
|
|
1876
|
+
this.registry.addProvider(new ComfyUIProvider({
|
|
1877
|
+
host: comfyuiCfg.host, // Default: 'http://localhost'
|
|
1878
|
+
port: comfyuiCfg.port, // Default: 8188
|
|
1879
|
+
}));
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
```
|
|
1883
|
+
|
|
1884
|
+
**Environment variable overrides:**
|
|
1885
|
+
```bash
|
|
1886
|
+
COMFYUI_HOST=http://192.168.1.100 # Override host
|
|
1887
|
+
COMFYUI_PORT=8190 # Override port
|
|
1888
|
+
```
|
|
1889
|
+
|
|
1890
|
+
#### Configuration
|
|
1891
|
+
|
|
1892
|
+
```typescript
|
|
1893
|
+
const ai = new Noosphere({
|
|
1894
|
+
local: {
|
|
1895
|
+
comfyui: {
|
|
1896
|
+
enabled: true, // Default: true (auto-detected)
|
|
1897
|
+
host: 'http://localhost', // Default: 'http://localhost'
|
|
1898
|
+
port: 8188, // Default: 8188
|
|
1899
|
+
},
|
|
1900
|
+
},
|
|
1901
|
+
});
|
|
1902
|
+
```
|
|
1903
|
+
|
|
1904
|
+
#### Model Discovery — Dynamic via /object_info
|
|
1905
|
+
|
|
1906
|
+
```typescript
|
|
1907
|
+
async listModels(modality?: Modality): Promise<ModelInfo[]> {
|
|
1908
|
+
// Fetches ComfyUI's full node registry:
|
|
1909
|
+
const res = await fetch(`${this.baseUrl}/object_info`);
|
|
1910
|
+
if (!res.ok) return [];
|
|
1911
|
+
|
|
1912
|
+
// Does NOT parse the response — just uses it as a connectivity check.
|
|
1913
|
+
// Returns hardcoded model entries:
|
|
1914
|
+
const models: ModelInfo[] = [];
|
|
1915
|
+
if (!modality || modality === 'image') {
|
|
1916
|
+
models.push({
|
|
1917
|
+
id: 'comfyui-txt2img',
|
|
1918
|
+
provider: 'comfyui',
|
|
1919
|
+
name: 'ComfyUI Text-to-Image',
|
|
1920
|
+
modality: 'image',
|
|
1921
|
+
local: true,
|
|
1922
|
+
cost: { price: 0, unit: 'free' },
|
|
1923
|
+
capabilities: { maxWidth: 2048, maxHeight: 2048, supportsNegativePrompt: true },
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
if (!modality || modality === 'video') {
|
|
1927
|
+
models.push({
|
|
1928
|
+
id: 'comfyui-txt2vid',
|
|
1929
|
+
provider: 'comfyui',
|
|
1930
|
+
name: 'ComfyUI Text-to-Video',
|
|
1931
|
+
modality: 'video',
|
|
1932
|
+
local: true,
|
|
1933
|
+
cost: { price: 0, unit: 'free' },
|
|
1934
|
+
capabilities: { maxDuration: 10, supportsImageToVideo: true },
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
return models;
|
|
1938
|
+
}
|
|
1939
|
+
// NOTE: /object_info is fetched but the response is discarded.
|
|
1940
|
+
// The actual model list is hardcoded. This means even if you have
|
|
1941
|
+
// dozens of checkpoints in ComfyUI, Noosphere only exposes 2 model IDs.
|
|
1942
|
+
```
|
|
1943
|
+
|
|
1944
|
+
#### Video Generation — Not Yet Implemented
|
|
1945
|
+
|
|
1946
|
+
```typescript
|
|
1947
|
+
async video(_options: VideoOptions): Promise<NoosphereResult> {
|
|
1948
|
+
throw new Error('ComfyUI video generation requires a configured AnimateDiff workflow');
|
|
1949
|
+
}
|
|
1950
|
+
// The 'comfyui-txt2vid' model ID is listed but will throw at runtime.
|
|
1951
|
+
// This is a placeholder for future AnimateDiff/SVD workflow templates.
|
|
1952
|
+
```
|
|
1953
|
+
|
|
1954
|
+
#### Default Workflow Parameters Summary
|
|
1955
|
+
|
|
1956
|
+
| Parameter | Default | Configurable | Node |
|
|
1957
|
+
|---|---|---|---|
|
|
1958
|
+
| Checkpoint | `sd_xl_base_1.0.safetensors` | No | Node 4 |
|
|
1959
|
+
| Sampler | `euler` | No | Node 3 |
|
|
1960
|
+
| Scheduler | `normal` | No | Node 3 |
|
|
1961
|
+
| Denoise | `1.0` | No | Node 3 |
|
|
1962
|
+
| Steps | `20` | Yes (`options.steps`) | Node 3 |
|
|
1963
|
+
| CFG/Guidance | `7` | Yes (`options.guidanceScale`) | Node 3 |
|
|
1964
|
+
| Seed | `0` | Yes (`options.seed`) | Node 3 |
|
|
1965
|
+
| Width | `1024` | Yes (`options.width`) | Node 5 |
|
|
1966
|
+
| Height | `1024` | Yes (`options.height`) | Node 5 |
|
|
1967
|
+
| Batch Size | `1` | No | Node 5 |
|
|
1968
|
+
| Filename Prefix | `noosphere` | No | Node 9 |
|
|
1969
|
+
| Negative Prompt | `''` (empty) | Yes (`options.negativePrompt`) | Node 7 |
|
|
1970
|
+
| Max Size | `2048x2048` | Via options | Node 5 |
|
|
1971
|
+
| Output Format | PNG | No | ComfyUI default |
|
|
1972
|
+
|
|
1973
|
+
---
|
|
1974
|
+
|
|
1975
|
+
### Local TTS — Piper & Kokoro
|
|
1976
|
+
|
|
1977
|
+
**Provider IDs:** `piper`, `kokoro`
|
|
1978
|
+
**Modality:** TTS
|
|
1979
|
+
**Type:** Local
|
|
1980
|
+
**Source:** `src/providers/local-tts.ts` (112 lines)
|
|
1981
|
+
|
|
1982
|
+
The `LocalTTSProvider` is a generic adapter for any local TTS server that exposes an OpenAI-compatible `/v1/audio/speech` endpoint. Two instances are created by default — one for Piper, one for Kokoro — but the class works with ANY server implementing this protocol.
|
|
1983
|
+
|
|
1984
|
+
#### Supported Engines
|
|
1985
|
+
|
|
1986
|
+
| Engine | Default Port | Health Check | Voice Discovery | Description |
|
|
1987
|
+
|---|---|---|---|---|
|
|
1988
|
+
| Piper | 5500 | `GET /health` | `GET /voices` (array) | Fast offline TTS, 30+ languages, ONNX models |
|
|
1989
|
+
| Kokoro | 5501 | `GET /health` | `GET /v1/models` (OpenAI format) | High-quality neural TTS |
|
|
1990
|
+
|
|
1991
|
+
#### Provider Instantiation — How Instances Are Created
|
|
1992
|
+
|
|
1993
|
+
```typescript
|
|
1994
|
+
// The LocalTTSProvider constructor takes a config object:
|
|
1995
|
+
interface LocalTTSConfig {
|
|
1996
|
+
id: string; // Provider ID: 'piper' or 'kokoro'
|
|
1997
|
+
name: string; // Display name: 'Piper TTS' or 'Kokoro TTS'
|
|
1998
|
+
host: string; // Base URL host
|
|
1999
|
+
port: number; // Port number
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// Two separate instances are created during init():
|
|
2003
|
+
new LocalTTSProvider({ id: 'piper', name: 'Piper TTS', host: piperCfg.host, port: piperCfg.port })
|
|
2004
|
+
new LocalTTSProvider({ id: 'kokoro', name: 'Kokoro TTS', host: kokoroCfg.host, port: kokoroCfg.port })
|
|
2005
|
+
|
|
2006
|
+
// Each instance is an independent provider in the registry.
|
|
2007
|
+
// They don't share state or config.
|
|
2008
|
+
// The baseUrl is constructed as: `${config.host}:${config.port}`
|
|
2009
|
+
// Example: "http://localhost:5500"
|
|
2010
|
+
```
|
|
2011
|
+
|
|
2012
|
+
#### Health Check — Ping Protocol
|
|
2013
|
+
|
|
2014
|
+
```typescript
|
|
2015
|
+
async ping(): Promise<boolean> {
|
|
2016
|
+
try {
|
|
2017
|
+
const res = await fetch(`${this.baseUrl}/health`);
|
|
2018
|
+
return res.ok; // true if HTTP 200-299
|
|
2019
|
+
} catch {
|
|
2020
|
+
return false; // Network error, connection refused, etc.
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
// Used during auto-detection in Noosphere.init()
|
|
2024
|
+
// Also used by: the 2-second AbortController timeout in init()
|
|
2025
|
+
// Note: /health is checked BEFORE the provider is registered.
|
|
2026
|
+
// If /health fails, the provider is silently skipped.
|
|
2027
|
+
```
|
|
2028
|
+
|
|
2029
|
+
#### Dual Voice Discovery Mechanism
|
|
2030
|
+
|
|
2031
|
+
The `listModels()` method implements a **two-strategy fallback** to discover available voices. This is necessary because different TTS servers expose voices through different API formats:
|
|
2032
|
+
|
|
2033
|
+
```typescript
|
|
2034
|
+
async listModels(modality?: Modality): Promise<ModelInfo[]> {
|
|
2035
|
+
if (modality && modality !== 'tts') return [];
|
|
2036
|
+
|
|
2037
|
+
let voices: Array<{ id: string; name?: string }> = [];
|
|
2038
|
+
|
|
2039
|
+
// STRATEGY 1: Piper-style /voices endpoint
|
|
2040
|
+
// Expected response: Array<{ id: string, name?: string, ... }>
|
|
2041
|
+
try {
|
|
2042
|
+
const res = await fetch(`${this.baseUrl}/voices`);
|
|
2043
|
+
if (res.ok) {
|
|
2044
|
+
const data = await res.json();
|
|
2045
|
+
if (Array.isArray(data)) {
|
|
2046
|
+
voices = data;
|
|
2047
|
+
// Success — skip fallback
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
} catch {
|
|
2051
|
+
// STRATEGY 2: OpenAI-compatible /v1/models endpoint
|
|
2052
|
+
// Expected response: { data: Array<{ id: string, ... }> }
|
|
2053
|
+
const res = await fetch(`${this.baseUrl}/v1/models`);
|
|
2054
|
+
if (res.ok) {
|
|
2055
|
+
const data = await res.json();
|
|
2056
|
+
voices = data.data ?? [];
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// Map voices to ModelInfo objects:
|
|
2061
|
+
return voices.map((v) => ({
|
|
2062
|
+
id: v.id,
|
|
2063
|
+
provider: this.id, // 'piper' or 'kokoro'
|
|
2064
|
+
name: v.name ?? v.id, // Fallback to ID if no name
|
|
2065
|
+
modality: 'tts' as const,
|
|
2066
|
+
local: true,
|
|
2067
|
+
cost: { price: 0, unit: 'free' },
|
|
2068
|
+
capabilities: {
|
|
2069
|
+
voices: voices.map((vv) => vv.id), // All voice IDs as capabilities
|
|
2070
|
+
},
|
|
2071
|
+
}));
|
|
2072
|
+
}
|
|
2073
|
+
```
|
|
2074
|
+
|
|
2075
|
+
**Critical implementation detail:** The fallback is triggered by a `catch` block, NOT by checking the response. This means:
|
|
2076
|
+
- If `/voices` returns a **non-array** (e.g., `{}`), strategy 1 succeeds but `voices` remains empty
|
|
2077
|
+
- If `/voices` returns HTTP **404**, strategy 1 "succeeds" (no exception), but `res.ok` is false, so voices stays empty, AND strategy 2 is never tried
|
|
2078
|
+
- Strategy 2 only runs if `/voices` **throws a network error** (connection refused, DNS failure, etc.)
|
|
2079
|
+
|
|
2080
|
+
**Piper response format** (`GET /voices`):
|
|
2081
|
+
```json
|
|
2082
|
+
[
|
|
2083
|
+
{ "id": "en_US-lessac-medium", "name": "Lessac (English US)" },
|
|
2084
|
+
{ "id": "en_US-amy-medium", "name": "Amy (English US)" },
|
|
2085
|
+
{ "id": "de_DE-thorsten-high", "name": "Thorsten (German)" }
|
|
2086
|
+
]
|
|
2087
|
+
```
|
|
2088
|
+
|
|
2089
|
+
**Kokoro/OpenAI response format** (`GET /v1/models`):
|
|
2090
|
+
```json
|
|
2091
|
+
{
|
|
2092
|
+
"data": [
|
|
2093
|
+
{ "id": "kokoro-v1", "object": "model" },
|
|
2094
|
+
{ "id": "kokoro-v1-jp", "object": "model" }
|
|
2095
|
+
]
|
|
2096
|
+
}
|
|
2097
|
+
```
|
|
2098
|
+
|
|
2099
|
+
#### Speech Generation — Exact HTTP Protocol
|
|
2100
|
+
|
|
2101
|
+
```typescript
|
|
2102
|
+
async speak(options: SpeakOptions): Promise<NoosphereResult> {
|
|
2103
|
+
const start = Date.now();
|
|
2104
|
+
|
|
2105
|
+
// POST to OpenAI-compatible TTS endpoint:
|
|
2106
|
+
const res = await fetch(`${this.baseUrl}/v1/audio/speech`, {
|
|
2107
|
+
method: 'POST',
|
|
2108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2109
|
+
body: JSON.stringify({
|
|
2110
|
+
model: options.model ?? 'tts-1', // Default model ID
|
|
2111
|
+
input: options.text, // Text to synthesize
|
|
2112
|
+
voice: options.voice ?? 'default', // Voice selection
|
|
2113
|
+
speed: options.speed ?? 1.0, // Playback speed multiplier
|
|
2114
|
+
response_format: options.format ?? 'mp3', // Output audio format
|
|
2115
|
+
}),
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
if (!res.ok) {
|
|
2119
|
+
throw new Error(`Local TTS failed: ${res.status} ${await res.text()}`);
|
|
2120
|
+
// Note: error includes the response body text for debugging
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// Response is raw audio binary — convert to Buffer:
|
|
2124
|
+
const audioBuffer = Buffer.from(await res.arrayBuffer());
|
|
2125
|
+
|
|
2126
|
+
return {
|
|
2127
|
+
buffer: audioBuffer,
|
|
2128
|
+
provider: this.id, // 'piper' or 'kokoro'
|
|
2129
|
+
model: options.model ?? options.voice ?? 'default', // Fallback chain
|
|
2130
|
+
modality: 'tts',
|
|
2131
|
+
latencyMs: Date.now() - start,
|
|
2132
|
+
usage: {
|
|
2133
|
+
cost: 0, // Always free (local)
|
|
2134
|
+
input: options.text.length, // CHARACTER count, not tokens
|
|
2135
|
+
unit: 'characters', // Track by characters
|
|
2136
|
+
},
|
|
2137
|
+
media: {
|
|
2138
|
+
format: options.format ?? 'mp3', // Matches requested format
|
|
2139
|
+
},
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
```
|
|
2143
|
+
|
|
2144
|
+
**Request/Response details:**
|
|
2145
|
+
| Field | Value | Notes |
|
|
2146
|
+
|---|---|---|
|
|
2147
|
+
| Method | `POST` | Always POST |
|
|
2148
|
+
| URL | `/v1/audio/speech` | OpenAI-compatible standard |
|
|
2149
|
+
| Content-Type | `application/json` | JSON body |
|
|
2150
|
+
| Response Content-Type | `audio/mpeg`, `audio/wav`, or `audio/ogg` | Depends on `response_format` |
|
|
2151
|
+
| Response Body | Raw binary audio | Converted to `Buffer` via `arrayBuffer()` |
|
|
2152
|
+
|
|
2153
|
+
**Available formats (from `SpeakOptions.format` type):**
|
|
2154
|
+
| Format | Typical Size | Quality | Use Case |
|
|
2155
|
+
|---|---|---|---|
|
|
2156
|
+
| `mp3` | Smallest | Lossy | Web playback, storage |
|
|
2157
|
+
| `wav` | Largest | Lossless | Processing, editing |
|
|
2158
|
+
| `ogg` | Medium | Lossy | Web playback, open format |
|
|
2159
|
+
|
|
2160
|
+
#### Usage Tracking — Character-Based
|
|
2161
|
+
|
|
2162
|
+
Local TTS tracks usage by **character count**, not tokens:
|
|
2163
|
+
|
|
2164
|
+
```typescript
|
|
2165
|
+
usage: {
|
|
2166
|
+
cost: 0, // Always 0 for local providers
|
|
2167
|
+
input: options.text.length, // JavaScript string .length (UTF-16 code units)
|
|
2168
|
+
unit: 'characters', // Unit identifier for aggregation
|
|
2169
|
+
}
|
|
2170
|
+
// Note: .length counts UTF-16 code units, not Unicode codepoints.
|
|
2171
|
+
// "Hello" = 5, "🎵" = 2 (surrogate pair), "café" = 4
|
|
2172
|
+
```
|
|
2173
|
+
|
|
2174
|
+
This feeds into the global `UsageTracker`, so you can query TTS usage:
|
|
2175
|
+
```typescript
|
|
2176
|
+
const usage = ai.getUsage({ modality: 'tts' });
|
|
2177
|
+
// usage.totalRequests = number of TTS calls
|
|
2178
|
+
// usage.totalCost = 0 (always free for local)
|
|
2179
|
+
// usage.byProvider = { piper: 0, kokoro: 0 }
|
|
2180
|
+
```
|
|
2181
|
+
|
|
2182
|
+
#### Auto-Detection — Parallel Discovery
|
|
2183
|
+
|
|
2184
|
+
Both Piper and Kokoro are detected simultaneously during `init()`:
|
|
2185
|
+
|
|
2186
|
+
```typescript
|
|
2187
|
+
// Inside Noosphere.init(), wrapped in Promise.allSettled():
|
|
2188
|
+
await Promise.allSettled([
|
|
2189
|
+
// ... ComfyUI detection ...
|
|
2190
|
+
(async () => {
|
|
2191
|
+
if (piperCfg?.enabled) { // enabled: true by default
|
|
2192
|
+
const ok = await pingUrl(`${piperCfg.host}:${piperCfg.port}/health`);
|
|
2193
|
+
if (ok) {
|
|
2194
|
+
this.registry.addProvider(new LocalTTSProvider({
|
|
2195
|
+
id: 'piper', name: 'Piper TTS',
|
|
2196
|
+
host: piperCfg.host, port: piperCfg.port,
|
|
2197
|
+
}));
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
})(),
|
|
2201
|
+
(async () => {
|
|
2202
|
+
if (kokoroCfg?.enabled) { // enabled: true by default
|
|
2203
|
+
const ok = await pingUrl(`${kokoroCfg.host}:${kokoroCfg.port}/health`);
|
|
2204
|
+
if (ok) {
|
|
2205
|
+
this.registry.addProvider(new LocalTTSProvider({
|
|
2206
|
+
id: 'kokoro', name: 'Kokoro TTS',
|
|
2207
|
+
host: kokoroCfg.host, port: kokoroCfg.port,
|
|
2208
|
+
}));
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
})(),
|
|
2212
|
+
]);
|
|
2213
|
+
```
|
|
2214
|
+
|
|
2215
|
+
**Environment variable overrides:**
|
|
2216
|
+
```bash
|
|
2217
|
+
PIPER_HOST=http://192.168.1.100 PIPER_PORT=5500
|
|
2218
|
+
KOKORO_HOST=http://192.168.1.100 KOKORO_PORT=5501
|
|
2219
|
+
```
|
|
2220
|
+
|
|
2221
|
+
#### Setting Up Local TTS Servers
|
|
2222
|
+
|
|
2223
|
+
**Piper TTS:**
|
|
2224
|
+
```bash
|
|
2225
|
+
# Docker (recommended):
|
|
2226
|
+
docker run -p 5500:5500 rhasspy/wyoming-piper \
|
|
2227
|
+
--voice en_US-lessac-medium
|
|
2228
|
+
|
|
2229
|
+
# Or via pip:
|
|
2230
|
+
pip install piper-tts
|
|
2231
|
+
# Then run a compatible HTTP server (wyoming-piper or piper-http-server)
|
|
2232
|
+
```
|
|
2233
|
+
|
|
2234
|
+
**Kokoro TTS:**
|
|
2235
|
+
```bash
|
|
2236
|
+
# Docker:
|
|
2237
|
+
docker run -p 5501:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest
|
|
2238
|
+
|
|
2239
|
+
# The Kokoro server exposes OpenAI-compatible endpoints at:
|
|
2240
|
+
# GET /v1/models → List available voices
|
|
2241
|
+
# POST /v1/audio/speech → Generate speech
|
|
2242
|
+
# GET /health → Health check
|
|
2243
|
+
```
|
|
2244
|
+
|
|
2245
|
+
---
|
|
2246
|
+
|
|
2247
|
+
## Architecture
|
|
2248
|
+
|
|
2249
|
+
### The Complete Init() Flow — What Happens When You Create a Noosphere Instance
|
|
2250
|
+
|
|
2251
|
+
```typescript
|
|
2252
|
+
const ai = new Noosphere({ /* config */ });
|
|
2253
|
+
// At this point: config is resolved, but NO providers are registered.
|
|
2254
|
+
// The `initialized` flag is false.
|
|
2255
|
+
|
|
2256
|
+
await ai.chat({ messages: [...] });
|
|
2257
|
+
// FIRST call triggers lazy initialization via init()
|
|
2258
|
+
```
|
|
2259
|
+
|
|
2260
|
+
**Initialization sequence (`src/noosphere.ts:240-322`):**
|
|
2261
|
+
|
|
2262
|
+
```
|
|
2263
|
+
1. Constructor:
|
|
2264
|
+
├── resolveConfig(input) // Merge config > env > defaults
|
|
2265
|
+
├── new Registry(cacheTTLMinutes) // Empty provider registry
|
|
2266
|
+
└── new UsageTracker(onUsage) // Empty event list
|
|
2267
|
+
|
|
2268
|
+
2. First API call triggers init():
|
|
2269
|
+
├── Set initialized = true (immediately, before any async work)
|
|
2270
|
+
│
|
|
2271
|
+
├── CLOUD PROVIDER REGISTRATION (synchronous):
|
|
2272
|
+
│ ├── Collect all API keys from resolved config
|
|
2273
|
+
│ ├── If ANY LLM key exists → register PiAiProvider(allKeys)
|
|
2274
|
+
│ ├── If FAL key exists → register FalProvider(falKey)
|
|
2275
|
+
│ └── If HF token exists → register HuggingFaceProvider(token)
|
|
2276
|
+
│
|
|
2277
|
+
└── LOCAL SERVICE DETECTION (parallel, async):
|
|
2278
|
+
└── Promise.allSettled([
|
|
2279
|
+
pingUrl(comfyui /system_stats) → register ComfyUIProvider
|
|
2280
|
+
pingUrl(piper /health) → register LocalTTSProvider('piper')
|
|
2281
|
+
pingUrl(kokoro /health) → register LocalTTSProvider('kokoro')
|
|
2282
|
+
])
|
|
2283
|
+
```
|
|
2284
|
+
|
|
2285
|
+
**Key design decisions:**
|
|
2286
|
+
- `initialized = true` is set **before** async work, preventing concurrent init() calls
|
|
2287
|
+
- Cloud providers are registered **synchronously** (no network calls needed)
|
|
2288
|
+
- Local detection uses `Promise.allSettled()` — a failing ping doesn't block others
|
|
2289
|
+
- Each ping has a 2-second `AbortController` timeout
|
|
2290
|
+
- If auto-detection is disabled (`autoDetectLocal: false`), local providers are never registered
|
|
2291
|
+
|
|
2292
|
+
### Configuration Resolution — Three-Layer Priority System
|
|
2293
|
+
|
|
2294
|
+
The `resolveConfig()` function (`src/config.ts`, 87 lines) implements a strict priority hierarchy:
|
|
2295
|
+
|
|
2296
|
+
```
|
|
2297
|
+
Priority: Explicit Config > Environment Variables > Built-in Defaults
|
|
2298
|
+
```
|
|
2299
|
+
|
|
2300
|
+
**API Key Resolution:**
|
|
2301
|
+
```typescript
|
|
2302
|
+
// For each of the 9 supported providers:
|
|
2303
|
+
const ENV_KEY_MAP = {
|
|
2304
|
+
openai: 'OPENAI_API_KEY',
|
|
2305
|
+
anthropic: 'ANTHROPIC_API_KEY',
|
|
2306
|
+
google: 'GEMINI_API_KEY',
|
|
2307
|
+
fal: 'FAL_KEY',
|
|
2308
|
+
openrouter: 'OPENROUTER_API_KEY',
|
|
2309
|
+
huggingface: 'HUGGINGFACE_TOKEN',
|
|
2310
|
+
groq: 'GROQ_API_KEY',
|
|
2311
|
+
mistral: 'MISTRAL_API_KEY',
|
|
2312
|
+
xai: 'XAI_API_KEY',
|
|
2313
|
+
};
|
|
2314
|
+
|
|
2315
|
+
// Resolution per key:
|
|
2316
|
+
keys[name] = input.keys?.[name] // 1. Explicit config
|
|
2317
|
+
?? process.env[envVar]; // 2. Environment variable
|
|
2318
|
+
// 3. undefined (no default)
|
|
2319
|
+
```
|
|
2320
|
+
|
|
2321
|
+
**Local Service Resolution:**
|
|
2322
|
+
```typescript
|
|
2323
|
+
// For each of the 4 local services:
|
|
2324
|
+
const LOCAL_DEFAULTS = {
|
|
2325
|
+
ollama: { host: 'http://localhost', port: 11434, envHost: 'OLLAMA_HOST', envPort: 'OLLAMA_PORT' },
|
|
2326
|
+
comfyui: { host: 'http://localhost', port: 8188, envHost: 'COMFYUI_HOST', envPort: 'COMFYUI_PORT' },
|
|
2327
|
+
piper: { host: 'http://localhost', port: 5500, envHost: 'PIPER_HOST', envPort: 'PIPER_PORT' },
|
|
2328
|
+
kokoro: { host: 'http://localhost', port: 5501, envHost: 'KOKORO_HOST', envPort: 'KOKORO_PORT' },
|
|
2329
|
+
};
|
|
2330
|
+
|
|
2331
|
+
// Resolution per service:
|
|
2332
|
+
local[name] = {
|
|
2333
|
+
enabled: cfgLocal?.enabled ?? true, // Default: enabled
|
|
2334
|
+
host: cfgLocal?.host ?? process.env[envHost] ?? defaults.host,
|
|
2335
|
+
port: cfgLocal?.port ?? parseInt(process.env[envPort]) ?? defaults.port,
|
|
2336
|
+
type: cfgLocal?.type,
|
|
2337
|
+
};
|
|
2338
|
+
```
|
|
2339
|
+
|
|
2340
|
+
**Other config defaults:**
|
|
2341
|
+
| Setting | Default | Environment Override |
|
|
2342
|
+
|---|---|---|
|
|
2343
|
+
| `autoDetectLocal` | `true` | `NOOSPHERE_AUTO_DETECT_LOCAL` |
|
|
2344
|
+
| `discoveryCacheTTL` | `60` (minutes) | `NOOSPHERE_DISCOVERY_CACHE_TTL` |
|
|
2345
|
+
| `retry.maxRetries` | `2` | — |
|
|
2346
|
+
| `retry.backoffMs` | `1000` | — |
|
|
2347
|
+
| `retry.failover` | `true` | — |
|
|
2348
|
+
| `retry.retryableErrors` | `['PROVIDER_UNAVAILABLE', 'RATE_LIMITED', 'TIMEOUT']` | — |
|
|
2349
|
+
| `timeout.llm` | `30000` (30s) | — |
|
|
2350
|
+
| `timeout.image` | `120000` (2m) | — |
|
|
2351
|
+
| `timeout.video` | `300000` (5m) | — |
|
|
2352
|
+
| `timeout.tts` | `60000` (1m) | — |
|
|
2353
|
+
|
|
2354
|
+
### Provider Resolution — Local-First Algorithm
|
|
2355
|
+
|
|
2356
|
+
When you call a generation method without specifying a provider, Noosphere resolves one automatically through a three-stage process in `resolveProviderForModality()` (`src/noosphere.ts:324-348`):
|
|
2357
|
+
|
|
2358
|
+
```typescript
|
|
2359
|
+
private resolveProviderForModality(
|
|
2360
|
+
modality: Modality,
|
|
2361
|
+
preferredId?: string,
|
|
2362
|
+
modelId?: string,
|
|
2363
|
+
): NoosphereProvider {
|
|
2364
|
+
|
|
2365
|
+
// STAGE 1: Model-based resolution
|
|
2366
|
+
// If model was specified WITHOUT a provider, search the registry cache
|
|
2367
|
+
if (modelId && !preferredId) {
|
|
2368
|
+
const resolved = this.registry.resolveModel(modelId, modality);
|
|
2369
|
+
if (resolved) return resolved.provider;
|
|
2370
|
+
// resolveModel() scans ALL cached models across ALL providers
|
|
2371
|
+
// looking for exact match on both modelId AND modality
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// STAGE 2: Default-based resolution
|
|
2375
|
+
// Check if user configured a default for this modality
|
|
2376
|
+
if (!preferredId) {
|
|
2377
|
+
const defaultCfg = this.config.defaults[modality];
|
|
2378
|
+
if (defaultCfg) {
|
|
2379
|
+
preferredId = defaultCfg.provider;
|
|
2380
|
+
// Now fall through to Stage 3 with this preferredId
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// STAGE 3: Provider registry resolution
|
|
2385
|
+
const provider = this.registry.resolveProvider(modality, preferredId);
|
|
2386
|
+
if (!provider) {
|
|
2387
|
+
throw new NoosphereError(
|
|
2388
|
+
`No provider available for modality '${modality}'`,
|
|
2389
|
+
{ code: 'NO_PROVIDER', ... }
|
|
2390
|
+
);
|
|
2391
|
+
}
|
|
2392
|
+
return provider;
|
|
2393
|
+
}
|
|
2394
|
+
```
|
|
2395
|
+
|
|
2396
|
+
**Registry.resolveProvider() — The local-first algorithm** (`src/registry.ts:31-46`):
|
|
2397
|
+
|
|
2398
|
+
```typescript
|
|
2399
|
+
resolveProvider(modality: Modality, preferredId?: string): NoosphereProvider | null {
|
|
2400
|
+
// If a specific provider was requested:
|
|
2401
|
+
if (preferredId) {
|
|
2402
|
+
const p = this.providers.get(preferredId);
|
|
2403
|
+
if (p && p.modalities.includes(modality)) return p;
|
|
2404
|
+
return null; // NOT found — returns null, NOT a fallback
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
// No preference — scan with local-first priority:
|
|
2408
|
+
let bestCloud: NoosphereProvider | null = null;
|
|
2409
|
+
|
|
2410
|
+
for (const p of this.providers.values()) {
|
|
2411
|
+
if (!p.modalities.includes(modality)) continue;
|
|
2412
|
+
|
|
2413
|
+
// LOCAL provider found → return IMMEDIATELY (first match wins)
|
|
2414
|
+
if (p.isLocal) return p;
|
|
2415
|
+
|
|
2416
|
+
// CLOUD provider → save as fallback (first cloud match only)
|
|
2417
|
+
if (!bestCloud) bestCloud = p;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
return bestCloud; // Return first cloud provider, or null
|
|
2421
|
+
}
|
|
2422
|
+
```
|
|
2423
|
+
|
|
2424
|
+
**Resolution priority diagram:**
|
|
2425
|
+
```
|
|
2426
|
+
ai.chat({ model: 'gpt-4o' })
|
|
2427
|
+
│
|
|
2428
|
+
├─ Stage 1: Search modelCache for 'gpt-4o' with modality 'llm'
|
|
2429
|
+
│ └── Found in pi-ai cache → return PiAiProvider
|
|
2430
|
+
│
|
|
2431
|
+
├─ Stage 2: (skipped — model resolved in Stage 1)
|
|
2432
|
+
│
|
|
2433
|
+
└─ Stage 3: (skipped — already resolved)
|
|
2434
|
+
|
|
2435
|
+
ai.image({ prompt: 'sunset' })
|
|
2436
|
+
│
|
|
2437
|
+
├─ Stage 1: (no model specified, skipped)
|
|
2438
|
+
│
|
|
2439
|
+
├─ Stage 2: Check config.defaults.image → none configured
|
|
2440
|
+
│
|
|
2441
|
+
└─ Stage 3: resolveProvider('image', undefined)
|
|
2442
|
+
├── Scan providers:
|
|
2443
|
+
│ ├── pi-ai: modalities=['llm'] → skip (no 'image')
|
|
2444
|
+
│ ├── comfyui: modalities=['image','video'], isLocal=true → RETURN
|
|
2445
|
+
│ └── (fal never reached — local wins)
|
|
2446
|
+
└── Returns ComfyUIProvider (local-first)
|
|
2447
|
+
|
|
2448
|
+
ai.image({ prompt: 'sunset' }) // No local ComfyUI running
|
|
2449
|
+
│
|
|
2450
|
+
└─ Stage 3: resolveProvider('image', undefined)
|
|
2451
|
+
├── Scan providers:
|
|
2452
|
+
│ ├── pi-ai: no 'image' → skip
|
|
2453
|
+
│ ├── fal: modalities=['image','video','tts'], isLocal=false → save as bestCloud
|
|
2454
|
+
│ └── huggingface: modalities=['image','tts','llm'], isLocal=false → already have bestCloud
|
|
2455
|
+
└── Returns FalProvider (first cloud fallback)
|
|
2456
|
+
```
|
|
2457
|
+
|
|
2458
|
+
### Retry & Failover Logic — Complete Algorithm
|
|
2459
|
+
|
|
2460
|
+
The `executeWithRetry()` method (`src/noosphere.ts:350-397`) implements a two-phase error handling strategy: same-provider retries, then cross-provider failover.
|
|
2461
|
+
|
|
2462
|
+
```typescript
|
|
2463
|
+
private async executeWithRetry<T>(
|
|
2464
|
+
modality: Modality,
|
|
2465
|
+
provider: NoosphereProvider,
|
|
2466
|
+
fn: () => Promise<T>,
|
|
2467
|
+
failoverFnFactory?: (alt: NoosphereProvider) => (() => Promise<T>) | null,
|
|
2468
|
+
): Promise<T> {
|
|
2469
|
+
const { maxRetries, backoffMs, retryableErrors, failover } = this.config.retry;
|
|
2470
|
+
// Default: maxRetries=2, backoffMs=1000, failover=true
|
|
2471
|
+
// retryableErrors = ['PROVIDER_UNAVAILABLE', 'RATE_LIMITED', 'TIMEOUT']
|
|
2472
|
+
let lastError: Error | undefined;
|
|
2473
|
+
|
|
2474
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2475
|
+
try {
|
|
2476
|
+
return await fn(); // Try the primary provider
|
|
2477
|
+
} catch (err) {
|
|
2478
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
2479
|
+
|
|
2480
|
+
const isNoosphereErr = err instanceof NoosphereError;
|
|
2481
|
+
const code = isNoosphereErr ? err.code : 'GENERATION_FAILED';
|
|
2482
|
+
|
|
2483
|
+
// GENERATION_FAILED is special:
|
|
2484
|
+
// - Retryable on same provider (bad prompt, transient model issue)
|
|
2485
|
+
// - NOT eligible for cross-provider failover
|
|
2486
|
+
const isRetryable = retryableErrors.includes(code) || code === 'GENERATION_FAILED';
|
|
2487
|
+
const allowsFailover = code !== 'GENERATION_FAILED' && retryableErrors.includes(code);
|
|
2488
|
+
|
|
2489
|
+
if (!isRetryable || attempt === maxRetries) {
|
|
2490
|
+
// FAILOVER PHASE: Try other providers
|
|
2491
|
+
if (failover && allowsFailover && failoverFnFactory) {
|
|
2492
|
+
const altProviders = this.registry.getAllProviders()
|
|
2493
|
+
.filter((p) => p.id !== provider.id && p.modalities.includes(modality));
|
|
2494
|
+
|
|
2495
|
+
for (const alt of altProviders) {
|
|
2496
|
+
try {
|
|
2497
|
+
const altFn = failoverFnFactory(alt);
|
|
2498
|
+
if (altFn) return await altFn(); // Success on alternate provider
|
|
2499
|
+
} catch {
|
|
2500
|
+
// Continue to next alternate provider
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
break; // All retries and failovers exhausted
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// RETRY: Exponential backoff on same provider
|
|
2508
|
+
const delay = backoffMs * Math.pow(2, attempt);
|
|
2509
|
+
// attempt=0: 1000ms, attempt=1: 2000ms, attempt=2: 4000ms
|
|
2510
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
throw lastError ?? new NoosphereError('Generation failed', { ... });
|
|
2515
|
+
}
|
|
2516
|
+
```
|
|
2517
|
+
|
|
2518
|
+
**Failover function factory pattern:**
|
|
2519
|
+
|
|
2520
|
+
Each generation method passes a factory function that creates the right call for alternate providers:
|
|
2521
|
+
```typescript
|
|
2522
|
+
// In chat():
|
|
2523
|
+
(alt) => alt.chat ? () => alt.chat!(options) : null
|
|
2524
|
+
// If the alternate provider has chat(), create a function to call it.
|
|
2525
|
+
// If not (e.g., ComfyUI for LLM), return null → skip this provider.
|
|
2526
|
+
|
|
2527
|
+
// In image():
|
|
2528
|
+
(alt) => alt.image ? () => alt.image!(options) : null
|
|
2529
|
+
|
|
2530
|
+
// In video():
|
|
2531
|
+
(alt) => alt.video ? () => alt.video!(options) : null
|
|
2532
|
+
|
|
2533
|
+
// In speak():
|
|
2534
|
+
(alt) => alt.speak ? () => alt.speak!(options) : null
|
|
2535
|
+
```
|
|
2536
|
+
|
|
2537
|
+
**Complete retry timeline example:**
|
|
2538
|
+
```
|
|
2539
|
+
ai.chat() with provider="pi-ai", maxRetries=2, backoffMs=1000
|
|
2540
|
+
|
|
2541
|
+
Attempt 0: pi-ai.chat() → RATE_LIMITED
|
|
2542
|
+
wait 1000ms (1000 * 2^0)
|
|
2543
|
+
Attempt 1: pi-ai.chat() → RATE_LIMITED
|
|
2544
|
+
wait 2000ms (1000 * 2^1)
|
|
2545
|
+
Attempt 2: pi-ai.chat() → RATE_LIMITED
|
|
2546
|
+
// maxRetries exhausted, RATE_LIMITED allows failover
|
|
2547
|
+
Failover 1: huggingface.chat() → 503 SERVICE_UNAVAILABLE
|
|
2548
|
+
Failover 2: (no more providers with 'llm' modality)
|
|
2549
|
+
throw last error (RATE_LIMITED from pi-ai)
|
|
2550
|
+
```
|
|
2551
|
+
|
|
2552
|
+
**Error classification matrix:**
|
|
2553
|
+
|
|
2554
|
+
| Error Code | Same-Provider Retry | Cross-Provider Failover | Typical Cause |
|
|
2555
|
+
|---|---|---|---|
|
|
2556
|
+
| `PROVIDER_UNAVAILABLE` | Yes | Yes | Server down, network error |
|
|
2557
|
+
| `RATE_LIMITED` | Yes | Yes | API quota exceeded |
|
|
2558
|
+
| `TIMEOUT` | Yes | Yes | Slow response |
|
|
2559
|
+
| `GENERATION_FAILED` | Yes | **No** | Bad prompt, model error |
|
|
2560
|
+
| `AUTH_FAILED` | No | No | Wrong API key |
|
|
2561
|
+
| `MODEL_NOT_FOUND` | No | No | Invalid model ID |
|
|
2562
|
+
| `INVALID_INPUT` | No | No | Bad parameters |
|
|
2563
|
+
| `NO_PROVIDER` | No | No | No provider registered |
|
|
2564
|
+
|
|
2565
|
+
### Model Registry — Internal Data Structures
|
|
2566
|
+
|
|
2567
|
+
The Registry (`src/registry.ts`, 137 lines) is the central nervous system that maps providers to models and handles model lookups.
|
|
2568
|
+
|
|
2569
|
+
**Internal state:**
|
|
2570
|
+
```typescript
|
|
2571
|
+
class Registry {
|
|
2572
|
+
// Provider storage: Map<providerId, providerInstance>
|
|
2573
|
+
private providers = new Map<string, NoosphereProvider>();
|
|
2574
|
+
// Example: { 'pi-ai' → PiAiProvider, 'fal' → FalProvider, 'comfyui' → ComfyUIProvider }
|
|
2575
|
+
|
|
2576
|
+
// Model cache: Map<providerId, { models: ModelInfo[], syncedAt: timestamp }>
|
|
2577
|
+
private modelCache = new Map<string, CachedModels>();
|
|
2578
|
+
// Example: {
|
|
2579
|
+
// 'pi-ai' → { models: [246 ModelInfo objects], syncedAt: 1710000000000 },
|
|
2580
|
+
// 'fal' → { models: [867 ModelInfo objects], syncedAt: 1710000000000 },
|
|
2581
|
+
// }
|
|
2582
|
+
|
|
2583
|
+
// Cache TTL in milliseconds (converted from minutes in constructor)
|
|
2584
|
+
private cacheTTLMs: number;
|
|
2585
|
+
// Default: 60 * 60 * 1000 = 3,600,000ms = 1 hour
|
|
2586
|
+
}
|
|
2587
|
+
```
|
|
2588
|
+
|
|
2589
|
+
**Cache staleness check:**
|
|
2590
|
+
```typescript
|
|
2591
|
+
isCacheStale(providerId: string): boolean {
|
|
2592
|
+
const cached = this.modelCache.get(providerId);
|
|
2593
|
+
if (!cached) return true; // No cache = stale
|
|
2594
|
+
return Date.now() - cached.syncedAt > this.cacheTTLMs;
|
|
2595
|
+
// Example: if syncedAt was 61 minutes ago and TTL is 60 minutes → stale
|
|
2596
|
+
}
|
|
2597
|
+
```
|
|
2598
|
+
|
|
2599
|
+
**Model resolution — linear scan across all caches:**
|
|
2600
|
+
```typescript
|
|
2601
|
+
resolveModel(modelId: string, modality: Modality):
|
|
2602
|
+
{ provider: NoosphereProvider; model: ModelInfo } | null {
|
|
2603
|
+
|
|
2604
|
+
// Scan EVERY provider's cached models:
|
|
2605
|
+
for (const [providerId, cached] of this.modelCache) {
|
|
2606
|
+
const model = cached.models.find(
|
|
2607
|
+
(m) => m.id === modelId && m.modality === modality
|
|
2608
|
+
);
|
|
2609
|
+
// Must match BOTH modelId AND modality
|
|
2610
|
+
if (model) {
|
|
2611
|
+
const provider = this.providers.get(providerId);
|
|
2612
|
+
if (provider) return { provider, model };
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
return null;
|
|
2616
|
+
}
|
|
2617
|
+
// Performance: O(n) where n = total models across all providers
|
|
2618
|
+
// With 246 Pi-AI + 867 FAL + 3 HuggingFace = ~1116 models to scan
|
|
2619
|
+
// This is fast enough for the use case (called once per request)
|
|
2620
|
+
```
|
|
2621
|
+
|
|
2622
|
+
**Sync mechanism:**
|
|
2623
|
+
```typescript
|
|
2624
|
+
async syncAll(): Promise<SyncResult> {
|
|
2625
|
+
const byProvider: Record<string, number> = {};
|
|
2626
|
+
const errors: string[] = [];
|
|
2627
|
+
let synced = 0;
|
|
2628
|
+
|
|
2629
|
+
// Sequential sync (NOT parallel) — one provider at a time:
|
|
2630
|
+
for (const provider of this.providers.values()) {
|
|
2631
|
+
try {
|
|
2632
|
+
const models = await provider.listModels();
|
|
2633
|
+
this.modelCache.set(provider.id, {
|
|
2634
|
+
models,
|
|
2635
|
+
syncedAt: Date.now(),
|
|
2636
|
+
});
|
|
2637
|
+
byProvider[provider.id] = models.length;
|
|
2638
|
+
synced += models.length;
|
|
2639
|
+
} catch (err) {
|
|
2640
|
+
errors.push(`${provider.id}: ${err.message}`);
|
|
2641
|
+
byProvider[provider.id] = 0;
|
|
2642
|
+
// Note: failed sync does NOT clear existing cache
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
return { synced, byProvider, errors };
|
|
2647
|
+
}
|
|
2648
|
+
```
|
|
2649
|
+
|
|
2650
|
+
**Provider info aggregation:**
|
|
2651
|
+
```typescript
|
|
2652
|
+
getProviderInfos(modality?: Modality): ProviderInfo[] {
|
|
2653
|
+
// Returns summary info for each registered provider:
|
|
2654
|
+
// {
|
|
2655
|
+
// id: 'pi-ai',
|
|
2656
|
+
// name: 'pi-ai (LLM Gateway)',
|
|
2657
|
+
// modalities: ['llm'],
|
|
2658
|
+
// local: false,
|
|
2659
|
+
// status: 'online', // Always 'online' — no live ping check
|
|
2660
|
+
// modelCount: 246, // From cache, or 0 if not synced
|
|
2661
|
+
// }
|
|
2662
|
+
}
|
|
2663
|
+
```
|
|
2664
|
+
|
|
2665
|
+
### Usage Tracking — In-Memory Event Store
|
|
2666
|
+
|
|
2667
|
+
The `UsageTracker` (`src/tracking.ts`, 57 lines) records every API call and provides filtered aggregation.
|
|
2668
|
+
|
|
2669
|
+
**Internal state:**
|
|
2670
|
+
```typescript
|
|
2671
|
+
class UsageTracker {
|
|
2672
|
+
private events: UsageEvent[] = []; // Append-only array
|
|
2673
|
+
private onUsage?: (event: UsageEvent) => void | Promise<void>; // Optional callback
|
|
2674
|
+
}
|
|
2675
|
+
```
|
|
2676
|
+
|
|
2677
|
+
**Recording flow — every API call creates a UsageEvent:**
|
|
2678
|
+
|
|
2679
|
+
```typescript
|
|
2680
|
+
// On SUCCESS (in Noosphere.trackUsage):
|
|
2681
|
+
const event: UsageEvent = {
|
|
2682
|
+
modality: result.modality, // 'llm' | 'image' | 'video' | 'tts'
|
|
2683
|
+
provider: result.provider, // 'pi-ai', 'fal', etc.
|
|
2684
|
+
model: result.model, // 'gpt-4o', 'flux-pro', etc.
|
|
2685
|
+
cost: result.usage.cost, // USD amount (0 for free/local)
|
|
2686
|
+
latencyMs: result.latencyMs, // Wall-clock milliseconds
|
|
2687
|
+
input: result.usage.input, // Input tokens or characters
|
|
2688
|
+
output: result.usage.output, // Output tokens (LLM only)
|
|
2689
|
+
unit: result.usage.unit, // 'tokens', 'characters', 'free'
|
|
2690
|
+
timestamp: new Date().toISOString(), // ISO 8601
|
|
2691
|
+
success: true,
|
|
2692
|
+
metadata, // User-provided metadata passthrough
|
|
2693
|
+
};
|
|
2694
|
+
|
|
2695
|
+
// On FAILURE (in Noosphere.trackError):
|
|
2696
|
+
const event: UsageEvent = {
|
|
2697
|
+
modality, provider,
|
|
2698
|
+
model: model ?? 'unknown',
|
|
2699
|
+
cost: 0, // No cost on failure
|
|
2700
|
+
latencyMs: Date.now() - startMs, // Time until failure
|
|
2701
|
+
timestamp: new Date().toISOString(),
|
|
2702
|
+
success: false,
|
|
2703
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2704
|
+
metadata,
|
|
2705
|
+
};
|
|
2706
|
+
```
|
|
2707
|
+
|
|
2708
|
+
**Query/aggregation — filtered summary:**
|
|
2709
|
+
```typescript
|
|
2710
|
+
getSummary(options?: UsageQueryOptions): UsageSummary {
|
|
2711
|
+
let filtered = this.events;
|
|
2712
|
+
|
|
2713
|
+
// Time-range filtering:
|
|
2714
|
+
if (options?.since) {
|
|
2715
|
+
const since = new Date(options.since).getTime();
|
|
2716
|
+
filtered = filtered.filter((e) => new Date(e.timestamp).getTime() >= since);
|
|
2717
|
+
}
|
|
2718
|
+
if (options?.until) {
|
|
2719
|
+
const until = new Date(options.until).getTime();
|
|
2720
|
+
filtered = filtered.filter((e) => new Date(e.timestamp).getTime() <= until);
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
// Provider/modality filtering:
|
|
2724
|
+
if (options?.provider) {
|
|
2725
|
+
filtered = filtered.filter((e) => e.provider === options.provider);
|
|
2726
|
+
}
|
|
2727
|
+
if (options?.modality) {
|
|
2728
|
+
filtered = filtered.filter((e) => e.modality === options.modality);
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
// Aggregation:
|
|
2732
|
+
const byProvider: Record<string, number> = {};
|
|
2733
|
+
const byModality = { llm: 0, image: 0, video: 0, tts: 0 };
|
|
2734
|
+
let totalCost = 0;
|
|
2735
|
+
|
|
2736
|
+
for (const event of filtered) {
|
|
2737
|
+
totalCost += event.cost;
|
|
2738
|
+
byProvider[event.provider] = (byProvider[event.provider] ?? 0) + event.cost;
|
|
2739
|
+
byModality[event.modality] += event.cost;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
return { totalCost, totalRequests: filtered.length, byProvider, byModality };
|
|
2743
|
+
}
|
|
2744
|
+
```
|
|
2745
|
+
|
|
2746
|
+
**Usage example:**
|
|
2747
|
+
```typescript
|
|
2748
|
+
// Get all usage:
|
|
2749
|
+
const all = ai.getUsage();
|
|
2750
|
+
// { totalCost: 0.42, totalRequests: 15, byProvider: { 'pi-ai': 0.40, 'fal': 0.02 }, byModality: { llm: 0.40, image: 0.02, video: 0, tts: 0 } }
|
|
2751
|
+
|
|
2752
|
+
// Get usage for last hour, LLM only:
|
|
2753
|
+
const recent = ai.getUsage({
|
|
2754
|
+
since: new Date(Date.now() - 3600000),
|
|
2755
|
+
modality: 'llm',
|
|
2756
|
+
});
|
|
2757
|
+
|
|
2758
|
+
// Get usage for a specific provider:
|
|
2759
|
+
const falUsage = ai.getUsage({ provider: 'fal' });
|
|
2760
|
+
|
|
2761
|
+
// Real-time callback (set in constructor):
|
|
2762
|
+
const ai = new Noosphere({
|
|
2763
|
+
onUsage: (event) => {
|
|
2764
|
+
console.log(`${event.provider}/${event.model}: $${event.cost} in ${event.latencyMs}ms`);
|
|
2765
|
+
// Or: send to analytics, update dashboard, check budget
|
|
2766
|
+
},
|
|
2767
|
+
});
|
|
2768
|
+
```
|
|
2769
|
+
|
|
2770
|
+
**Important limitations:**
|
|
2771
|
+
- Events are stored **in memory only** — lost on process restart
|
|
2772
|
+
- No deduplication — each retry/failover attempt creates a separate event
|
|
2773
|
+
- `clear()` wipes all history (called by `dispose()`)
|
|
2774
|
+
- The `onUsage` callback is `await`ed — a slow callback blocks the response return
|
|
2775
|
+
|
|
2776
|
+
### Streaming Architecture
|
|
2777
|
+
|
|
2778
|
+
The `stream()` method (`src/noosphere.ts:73-124`) wraps provider streams with usage tracking:
|
|
2779
|
+
|
|
2780
|
+
```typescript
|
|
2781
|
+
stream(options: ChatOptions): NoosphereStream {
|
|
2782
|
+
// Returns IMMEDIATELY (synchronous) — no await
|
|
2783
|
+
// The actual initialization happens lazily on first iteration
|
|
2784
|
+
|
|
2785
|
+
let innerStream: NoosphereStream | undefined;
|
|
2786
|
+
let finalResult: NoosphereResult | undefined;
|
|
2787
|
+
let providerRef: NoosphereProvider | undefined;
|
|
2788
|
+
|
|
2789
|
+
// Lazy init — runs on first for-await-of iteration:
|
|
2790
|
+
const ensureInit = async () => {
|
|
2791
|
+
if (!this.initialized) await this.init();
|
|
2792
|
+
if (!providerRef) {
|
|
2793
|
+
providerRef = this.resolveProviderForModality('llm', ...);
|
|
2794
|
+
if (!providerRef.stream) throw new NoosphereError(...);
|
|
2795
|
+
innerStream = providerRef.stream(options);
|
|
2796
|
+
}
|
|
2797
|
+
};
|
|
2798
|
+
|
|
2799
|
+
// Wrapped async iterator with usage tracking:
|
|
2800
|
+
const wrappedIterator = {
|
|
2801
|
+
async *[Symbol.asyncIterator]() {
|
|
2802
|
+
await ensureInit(); // Init on first next()
|
|
2803
|
+
for await (const event of innerStream!) {
|
|
2804
|
+
if (event.type === 'done' && event.result) {
|
|
2805
|
+
finalResult = event.result;
|
|
2806
|
+
await trackUsage(event.result); // Track when complete
|
|
2807
|
+
}
|
|
2808
|
+
yield event; // Pass events through
|
|
2809
|
+
}
|
|
2810
|
+
},
|
|
2811
|
+
};
|
|
2812
|
+
|
|
2813
|
+
return {
|
|
2814
|
+
[Symbol.asyncIterator]: () => wrappedIterator[Symbol.asyncIterator](),
|
|
2815
|
+
|
|
2816
|
+
// result() — consume entire stream and return final result:
|
|
2817
|
+
result: async () => {
|
|
2818
|
+
if (finalResult) return finalResult; // Already consumed
|
|
2819
|
+
for await (const event of wrappedIterator) {
|
|
2820
|
+
if (event.type === 'done') return event.result!;
|
|
2821
|
+
if (event.type === 'error') throw event.error;
|
|
2822
|
+
}
|
|
2823
|
+
throw new NoosphereError('Stream ended without result');
|
|
2824
|
+
},
|
|
2825
|
+
|
|
2826
|
+
// abort() — signal cancellation:
|
|
2827
|
+
abort: () => innerStream?.abort(),
|
|
2828
|
+
};
|
|
2829
|
+
}
|
|
2830
|
+
```
|
|
2831
|
+
|
|
2832
|
+
**Stream event types:**
|
|
2833
|
+
| Event Type | Fields | When |
|
|
2834
|
+
|---|---|---|
|
|
2835
|
+
| `text_delta` | `{ type, delta: string }` | Each text token |
|
|
2836
|
+
| `thinking_delta` | `{ type, delta: string }` | Each reasoning token |
|
|
2837
|
+
| `done` | `{ type, result: NoosphereResult }` | Stream complete |
|
|
2838
|
+
| `error` | `{ type, error: Error }` | Stream failed |
|
|
2839
|
+
|
|
2840
|
+
**Note:** Streaming does NOT use `executeWithRetry()`. If the stream fails, there's no automatic retry or failover. The error is yielded as an `error` event and also tracked via `trackError()`.
|
|
2841
|
+
|
|
2842
|
+
### Lifecycle Management — dispose()
|
|
2843
|
+
|
|
2844
|
+
```typescript
|
|
2845
|
+
async dispose(): Promise<void> {
|
|
2846
|
+
// 1. Call dispose() on every registered provider (if implemented):
|
|
2847
|
+
for (const provider of this.registry.getAllProviders()) {
|
|
2848
|
+
if (provider.dispose) {
|
|
2849
|
+
await provider.dispose();
|
|
2850
|
+
// Currently: no built-in provider implements dispose()
|
|
2851
|
+
// This is for custom providers that need cleanup
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
// 2. Clear the model cache:
|
|
2856
|
+
this.registry.clearCache();
|
|
2857
|
+
|
|
2858
|
+
// 3. Clear usage history:
|
|
2859
|
+
this.tracker.clear();
|
|
910
2860
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
```typescript
|
|
914
|
-
interface UsageEvent {
|
|
915
|
-
modality: 'llm' | 'image' | 'video' | 'tts';
|
|
916
|
-
provider: string;
|
|
917
|
-
model: string;
|
|
918
|
-
cost: number; // USD
|
|
919
|
-
latencyMs: number;
|
|
920
|
-
input?: number; // tokens or characters
|
|
921
|
-
output?: number; // tokens
|
|
922
|
-
unit?: string;
|
|
923
|
-
timestamp: string; // ISO 8601
|
|
924
|
-
success: boolean;
|
|
925
|
-
error?: string; // error message if failed
|
|
926
|
-
metadata?: Record<string, unknown>;
|
|
2861
|
+
// Note: does NOT set initialized=false
|
|
2862
|
+
// After dispose(), the instance is NOT reusable for new requests
|
|
927
2863
|
}
|
|
928
2864
|
```
|
|
929
2865
|
|