@profullstack/ai-providers 0.0.0-stage → 0.1.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/LICENSE +3 -0
- package/README.md +85 -2
- package/index.d.ts +195 -0
- package/package.json +42 -4
- package/src/c0mpute.js +251 -0
- package/src/cli.js +36 -0
- package/src/errors.js +51 -0
- package/src/index.js +100 -0
- package/src/json.js +65 -0
- package/src/paid.js +133 -0
- package/src/router.js +196 -0
package/LICENSE
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,86 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @profullstack/ai-providers
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One way for every Profullstack app to call a text model.
|
|
4
|
+
|
|
5
|
+
1. **c0mpute first, always.** Jobs go to the peer network through Infernet's chat API, peer nodes only (`p2p_only`), never Infernet's billed hosted fallback.
|
|
6
|
+
2. **OpenAI and Anthropic only as fallbacks, alternating.** Call 1 falls back to OpenAI then Anthropic, call 2 to Anthropic then OpenAI. Neither bill takes all the overflow.
|
|
7
|
+
3. **Dead keys are parked, not retried.** No credits or a bad key parks that provider for an hour. A 429, a 5xx or no free node parks it for 30 seconds, doubling up to 10 minutes. A success clears it.
|
|
8
|
+
|
|
9
|
+
No dependencies. Node 20+, Bun, Workers: anything with `fetch`.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm i @profullstack/ai-providers
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { generateText, generateObject } from '@profullstack/ai-providers';
|
|
19
|
+
|
|
20
|
+
const { text, provider, model } = await generateText({
|
|
21
|
+
system: 'You write short product copy.',
|
|
22
|
+
user: 'One line for a coffee grinder.',
|
|
23
|
+
maxTokens: 200,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// JSON: pulled out of whatever the model wrapped it in, validated, and
|
|
27
|
+
// repaired once on the same provider before moving to the next one.
|
|
28
|
+
import { z } from 'zod';
|
|
29
|
+
const Ad = z.object({ headline: z.string(), body: z.string() });
|
|
30
|
+
const { object } = await generateObject({
|
|
31
|
+
name: 'Ad',
|
|
32
|
+
schema: Ad,
|
|
33
|
+
jsonSchema: z.toJSONSchema(Ad),
|
|
34
|
+
user: 'Ad copy for crawlproof.com',
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`generateText` and `generateObject` share one router per process (`getAi()`), so rotation and parking see every call. Build your own with `createAiProviders({ env, c0mpute, openai, anthropic, router })`.
|
|
39
|
+
|
|
40
|
+
### Keep your own provider code
|
|
41
|
+
|
|
42
|
+
An app with tuned SDK calls (structured outputs, prompt caching, spend tracking) keeps them and uses the router only for order, rotation and parking:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
import { getAi, generateObjectOn } from '@profullstack/ai-providers';
|
|
46
|
+
|
|
47
|
+
const { provider, value } = await getAi().run(async (p) => {
|
|
48
|
+
if (p.name === 'anthropic') return myAnthropicCall();
|
|
49
|
+
if (p.name === 'openai') return myOpenAICall();
|
|
50
|
+
return (await generateObjectOn(p, req)).object; // c0mpute
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A thrown error with `status` 401/402/403, or a 429 or 400 that mentions credits or billing, parks that provider as `billing`.
|
|
55
|
+
|
|
56
|
+
## Environment
|
|
57
|
+
|
|
58
|
+
| Variable | Default | |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `C0MPUTE_API_KEY` | none | Infernet trusted-client key (`ifc_...`), one per app. Without it the public limit is 20 requests an hour per IP. |
|
|
61
|
+
| `C0MPUTE_URL` | `https://infernetprotocol.com` | |
|
|
62
|
+
| `C0MPUTE_MODELS` | `huihui_ai/qwen3.5-abliterated:9b,qwen2.5:7b,qwen3:4b,gemma3:4b,huihui_ai/qwen3.5-abliterated:4b` | Tried in order. The first is an Infernet house model every node pulls. |
|
|
63
|
+
| `C0MPUTE_ANY_MODEL` | off | Let any free node answer as a last resort. Small models write junk JSON. |
|
|
64
|
+
| `C0MPUTE_CONCURRENCY` | 2 | c0mpute jobs in flight per process. |
|
|
65
|
+
| `C0MPUTE_STALL_MS` | 90000 | A job that streams nothing this long is dropped and the next model tried. |
|
|
66
|
+
| `C0MPUTE_TIMEOUT_MS` | 300000 | Whole call. |
|
|
67
|
+
| `C0MPUTE_DISABLED` | off | `1` skips c0mpute. |
|
|
68
|
+
| `OPENAI_API_KEY`, `AI_OPENAI_MODEL` | `gpt-5-mini` | Fallback. |
|
|
69
|
+
| `ANTHROPIC_API_KEY`, `AI_ANTHROPIC_MODEL` | `claude-haiku-5-5` | Fallback. |
|
|
70
|
+
| `AI_PAID_FALLBACK` | on | `off` never calls a paid provider (OutreachGraph's rule). |
|
|
71
|
+
|
|
72
|
+
## CLI
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npx @profullstack/ai-providers status
|
|
76
|
+
npx @profullstack/ai-providers ask "one line about c0mpute"
|
|
77
|
+
npx @profullstack/ai-providers ask --json 'Return {"ok": true}'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## What c0mpute does not do
|
|
81
|
+
|
|
82
|
+
Images and embeddings. Apps that generate images keep their image provider and must already cope with it failing.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
export type ProviderName = 'c0mpute' | 'openai' | 'anthropic' | (string & {});
|
|
2
|
+
export type FailureKind = 'billing' | 'capacity' | 'invalid' | 'output';
|
|
3
|
+
|
|
4
|
+
export interface ChatMessage {
|
|
5
|
+
role: 'system' | 'user' | 'assistant';
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TextRequest {
|
|
10
|
+
system?: string;
|
|
11
|
+
/** Single user turn. Ignored when `messages` is set. */
|
|
12
|
+
user?: string;
|
|
13
|
+
messages?: ChatMessage[];
|
|
14
|
+
maxTokens?: number;
|
|
15
|
+
temperature?: number;
|
|
16
|
+
/** Ask for a JSON object (OpenAI json_object mode; the others are told so). */
|
|
17
|
+
json?: boolean;
|
|
18
|
+
/** Per-provider model override for this call. */
|
|
19
|
+
models?: { c0mpute?: string; openai?: string; anthropic?: string };
|
|
20
|
+
/** Label passed to onAttempt, for spend and failure logs. */
|
|
21
|
+
feature?: string;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Usage {
|
|
27
|
+
inputTokens: number;
|
|
28
|
+
outputTokens: number;
|
|
29
|
+
/** true when counted as characters / 4 (c0mpute reports no token counts). */
|
|
30
|
+
estimated: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ProviderOutput {
|
|
34
|
+
text: string;
|
|
35
|
+
model: string;
|
|
36
|
+
usage: Usage;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TextProvider {
|
|
40
|
+
readonly name: ProviderName;
|
|
41
|
+
generate(req: TextRequest): Promise<ProviderOutput>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Attempt {
|
|
45
|
+
provider: string;
|
|
46
|
+
error: ProviderError;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TextResult extends ProviderOutput {
|
|
50
|
+
provider: ProviderName;
|
|
51
|
+
attempts: Attempt[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Anything with safeParse (zod), parse, or a plain function. */
|
|
55
|
+
export type SchemaLike<T> =
|
|
56
|
+
| { safeParse(v: unknown): { success: true; data: T } | { success: false; error: any } }
|
|
57
|
+
| { parse(v: unknown): T }
|
|
58
|
+
| ((v: unknown) => T);
|
|
59
|
+
|
|
60
|
+
export interface ObjectRequest<T = unknown> extends TextRequest {
|
|
61
|
+
schema?: SchemaLike<T>;
|
|
62
|
+
/** Shown to the model. With zod 4: z.toJSONSchema(schema). */
|
|
63
|
+
jsonSchema?: Record<string, unknown>;
|
|
64
|
+
name?: string;
|
|
65
|
+
/** Repair attempts per provider after an unusable answer. Default 1. */
|
|
66
|
+
repairs?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ObjectResult<T> {
|
|
70
|
+
object: T;
|
|
71
|
+
text: string;
|
|
72
|
+
model: string;
|
|
73
|
+
usage: Usage;
|
|
74
|
+
provider: ProviderName;
|
|
75
|
+
attempts: Attempt[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AttemptEvent {
|
|
79
|
+
provider: string;
|
|
80
|
+
feature?: string;
|
|
81
|
+
ok: boolean;
|
|
82
|
+
ms: number;
|
|
83
|
+
kind?: FailureKind;
|
|
84
|
+
error?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface RouterOptions {
|
|
88
|
+
primary?: TextProvider[];
|
|
89
|
+
fallback?: TextProvider[];
|
|
90
|
+
/** Alternate the fallback order every call. Default true. */
|
|
91
|
+
rotate?: boolean;
|
|
92
|
+
billingMs?: number;
|
|
93
|
+
capacityMs?: number;
|
|
94
|
+
capacityMaxMs?: number;
|
|
95
|
+
onAttempt?: (e: AttemptEvent) => void;
|
|
96
|
+
now?: () => number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ProviderStatus {
|
|
100
|
+
name: string;
|
|
101
|
+
tier: 'primary' | 'fallback';
|
|
102
|
+
available: boolean;
|
|
103
|
+
parkedForMs?: number;
|
|
104
|
+
reason?: FailureKind;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export declare class AiRouter {
|
|
108
|
+
constructor(opts?: RouterOptions);
|
|
109
|
+
readonly primary: TextProvider[];
|
|
110
|
+
readonly fallback: TextProvider[];
|
|
111
|
+
readonly providers: TextProvider[];
|
|
112
|
+
plan(): { order: TextProvider[]; skipped: { name: string; waitMs: number; reason: string }[] };
|
|
113
|
+
status(): ProviderStatus[];
|
|
114
|
+
run<T, P extends { name: string } = TextProvider>(
|
|
115
|
+
task: (provider: P) => Promise<T>,
|
|
116
|
+
opts?: { feature?: string; only?: string[] },
|
|
117
|
+
): Promise<{ provider: ProviderName; value: T; attempts: Attempt[] }>;
|
|
118
|
+
generateText(req: TextRequest): Promise<TextResult>;
|
|
119
|
+
generateObject<T = unknown>(req: ObjectRequest<T>): Promise<ObjectResult<T>>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export declare function generateObjectOn<T = unknown>(
|
|
123
|
+
provider: TextProvider,
|
|
124
|
+
req: ObjectRequest<T>,
|
|
125
|
+
): Promise<{ object: T; text: string; model: string; usage: Usage }>;
|
|
126
|
+
|
|
127
|
+
export declare class ProviderError extends Error {
|
|
128
|
+
constructor(provider: string, message: string, opts?: { status?: number; kind?: FailureKind });
|
|
129
|
+
readonly provider: string;
|
|
130
|
+
readonly status: number;
|
|
131
|
+
readonly kind: FailureKind;
|
|
132
|
+
}
|
|
133
|
+
export declare class AllProvidersFailedError extends Error {
|
|
134
|
+
readonly attempts: Attempt[];
|
|
135
|
+
readonly skipped: { name: string; waitMs: number; reason: string }[];
|
|
136
|
+
}
|
|
137
|
+
export declare function classify(status: number, message?: string): FailureKind;
|
|
138
|
+
export declare function toProviderError(provider: string, err: unknown): ProviderError;
|
|
139
|
+
|
|
140
|
+
export interface C0mputeOptions {
|
|
141
|
+
apiKey?: string;
|
|
142
|
+
baseUrl?: string;
|
|
143
|
+
models?: string[];
|
|
144
|
+
anyModel?: boolean;
|
|
145
|
+
p2pOnly?: boolean;
|
|
146
|
+
timeoutMs?: number;
|
|
147
|
+
concurrency?: number;
|
|
148
|
+
/** No stream event for this long: try the next model. Default 90000. */
|
|
149
|
+
stallMs?: number;
|
|
150
|
+
fetch?: typeof fetch;
|
|
151
|
+
}
|
|
152
|
+
export declare const DEFAULT_C0MPUTE_URL: string;
|
|
153
|
+
export declare const DEFAULT_C0MPUTE_MODELS: string[];
|
|
154
|
+
export declare class C0mputeProvider implements TextProvider {
|
|
155
|
+
constructor(opts?: C0mputeOptions);
|
|
156
|
+
readonly name: 'c0mpute';
|
|
157
|
+
generate(req: TextRequest): Promise<ProviderOutput>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface PaidOptions {
|
|
161
|
+
apiKey?: string;
|
|
162
|
+
baseUrl?: string;
|
|
163
|
+
model?: string;
|
|
164
|
+
timeoutMs?: number;
|
|
165
|
+
fetch?: typeof fetch;
|
|
166
|
+
}
|
|
167
|
+
export declare const DEFAULT_OPENAI_MODEL: string;
|
|
168
|
+
export declare const DEFAULT_ANTHROPIC_MODEL: string;
|
|
169
|
+
export declare class OpenAIProvider implements TextProvider {
|
|
170
|
+
constructor(opts?: PaidOptions);
|
|
171
|
+
readonly name: 'openai';
|
|
172
|
+
generate(req: TextRequest): Promise<ProviderOutput>;
|
|
173
|
+
}
|
|
174
|
+
export declare class AnthropicProvider implements TextProvider {
|
|
175
|
+
constructor(opts?: PaidOptions);
|
|
176
|
+
readonly name: 'anthropic';
|
|
177
|
+
generate(req: TextRequest): Promise<ProviderOutput>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export declare function extractJson(text: string): unknown;
|
|
181
|
+
export declare function validate<T>(schema: SchemaLike<T> | undefined, value: unknown): T;
|
|
182
|
+
export declare function jsonInstructions(name?: string, jsonSchema?: Record<string, unknown>): string;
|
|
183
|
+
|
|
184
|
+
export interface CreateOptions {
|
|
185
|
+
env?: Record<string, string | undefined>;
|
|
186
|
+
c0mpute?: C0mputeOptions;
|
|
187
|
+
openai?: PaidOptions;
|
|
188
|
+
anthropic?: PaidOptions;
|
|
189
|
+
paidFallback?: boolean;
|
|
190
|
+
router?: Omit<RouterOptions, 'primary' | 'fallback'>;
|
|
191
|
+
}
|
|
192
|
+
export declare function createAiProviders(opts?: CreateOptions): AiRouter;
|
|
193
|
+
export declare function getAi(): AiRouter;
|
|
194
|
+
export declare function generateText(req: TextRequest): Promise<TextResult>;
|
|
195
|
+
export declare function generateObject<T = unknown>(req: ObjectRequest<T>): Promise<ObjectResult<T>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/ai-providers",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "One text-model call for every Profullstack app: c0mpute first, OpenAI and Anthropic as alternating fallbacks, dead keys parked. Zero dependencies.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"ai",
|
|
8
|
+
"llm",
|
|
9
|
+
"c0mpute",
|
|
10
|
+
"infernet",
|
|
11
|
+
"openai",
|
|
12
|
+
"anthropic",
|
|
13
|
+
"fallback",
|
|
14
|
+
"router"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/profullstack/ai-providers.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/profullstack/ai-providers#readme",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./index.d.ts",
|
|
25
|
+
"import": "./src/index.js",
|
|
26
|
+
"default": "./src/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"ai-providers": "./src/cli.js"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"index.d.ts",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node --test test/*.test.js"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/c0mpute.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c0mpute: inference on the peer network, through Infernet's chat API.
|
|
3
|
+
*
|
|
4
|
+
* POST /api/chat {messages, maxTokens, temperature, modelName?, p2p_only}
|
|
5
|
+
* -> {jobId, source, model}
|
|
6
|
+
* GET /api/chat/stream/<jobId> server-sent events: meta, token..., done | error
|
|
7
|
+
*
|
|
8
|
+
* Without a key the public playground allows 20 requests an hour per IP. A
|
|
9
|
+
* trusted-client key (`ifc_...`, one per app) has its own budget.
|
|
10
|
+
*
|
|
11
|
+
* `p2p_only` (default on) stops Infernet handing the job to its billed hosted
|
|
12
|
+
* fallback; an answer from anything but a peer node is refused.
|
|
13
|
+
*
|
|
14
|
+
* Models are tried in order. A small model on a free node writes junk JSON,
|
|
15
|
+
* so "any node" is off unless `anyModel` is set.
|
|
16
|
+
*/
|
|
17
|
+
import { ProviderError, toProviderError } from './errors.js';
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_C0MPUTE_URL = 'https://infernetprotocol.com';
|
|
20
|
+
export const DEFAULT_C0MPUTE_MODELS = [
|
|
21
|
+
// House models: every Infernet node pulls these (infernetprotocol.com/house-models.json).
|
|
22
|
+
'huihui_ai/qwen3.5-abliterated:9b',
|
|
23
|
+
'qwen2.5:7b',
|
|
24
|
+
'qwen3:4b',
|
|
25
|
+
'gemma3:4b',
|
|
26
|
+
'huihui_ai/qwen3.5-abliterated:4b',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export class C0mputeProvider {
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} [opts]
|
|
32
|
+
* @param {string} [opts.apiKey]
|
|
33
|
+
* @param {string} [opts.baseUrl]
|
|
34
|
+
* @param {string[]} [opts.models]
|
|
35
|
+
* @param {boolean} [opts.anyModel]
|
|
36
|
+
* @param {boolean} [opts.p2pOnly]
|
|
37
|
+
* @param {number} [opts.timeoutMs]
|
|
38
|
+
* @param {number} [opts.concurrency]
|
|
39
|
+
* @param {number} [opts.stallMs]
|
|
40
|
+
* @param {typeof fetch} [opts.fetch]
|
|
41
|
+
*/
|
|
42
|
+
constructor(opts = {}) {
|
|
43
|
+
this.name = 'c0mpute';
|
|
44
|
+
this.apiKey = opts.apiKey?.trim() || undefined;
|
|
45
|
+
this.baseUrl = (opts.baseUrl || DEFAULT_C0MPUTE_URL).replace(/\/+$/, '');
|
|
46
|
+
this.models = opts.models?.length ? [...opts.models] : [...DEFAULT_C0MPUTE_MODELS];
|
|
47
|
+
this.anyModel = opts.anyModel ?? false;
|
|
48
|
+
this.p2pOnly = opts.p2pOnly ?? true;
|
|
49
|
+
this.timeoutMs = opts.timeoutMs ?? 300_000;
|
|
50
|
+
// A job a node accepted but never answers: give up on that model after
|
|
51
|
+
// this long with no stream event and try the next one.
|
|
52
|
+
this.stallMs = opts.stallMs ?? 90_000;
|
|
53
|
+
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
54
|
+
this.slots = new Semaphore(opts.concurrency ?? 2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {import('../index.js').TextRequest} req */
|
|
58
|
+
async generate(req) {
|
|
59
|
+
const release = await this.slots.acquire();
|
|
60
|
+
const controller = new AbortController();
|
|
61
|
+
const timer = setTimeout(() => controller.abort(), req.timeoutMs ?? this.timeoutMs);
|
|
62
|
+
const onAbort = () => controller.abort();
|
|
63
|
+
req.signal?.addEventListener('abort', onAbort, { once: true });
|
|
64
|
+
try {
|
|
65
|
+
const messages = toMessages(req);
|
|
66
|
+
const candidates = [...(req.models?.c0mpute ? [req.models.c0mpute] : []), ...this.models];
|
|
67
|
+
const order = [...new Set(candidates)];
|
|
68
|
+
if (this.anyModel) order.push(undefined);
|
|
69
|
+
|
|
70
|
+
let last;
|
|
71
|
+
for (const model of order) {
|
|
72
|
+
const res = await this.fetch(`${this.baseUrl}/api/chat`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
'content-type': 'application/json',
|
|
76
|
+
accept: 'application/json',
|
|
77
|
+
...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}),
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
messages,
|
|
81
|
+
maxTokens: req.maxTokens ?? 1024,
|
|
82
|
+
temperature: req.temperature ?? 0.4,
|
|
83
|
+
...(this.p2pOnly ? { p2p_only: true } : {}),
|
|
84
|
+
...(model ? { modelName: model } : {}),
|
|
85
|
+
}),
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
});
|
|
88
|
+
const job = await res.json().catch(() => ({}));
|
|
89
|
+
if (res.status === 503) {
|
|
90
|
+
// No live node serves this model right now: next model.
|
|
91
|
+
last = new ProviderError('c0mpute', `${model ?? 'any'}: ${job.error ?? 'no node'}`, {
|
|
92
|
+
status: 503,
|
|
93
|
+
kind: 'capacity',
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!res.ok || !job.jobId) {
|
|
98
|
+
throw new ProviderError('c0mpute', job.error ?? `no job was created (${res.status})`, {
|
|
99
|
+
status: res.status,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (this.p2pOnly && job.source && job.source !== 'p2p') {
|
|
103
|
+
throw new ProviderError('c0mpute', `no peer node took the job (source ${job.source})`, {
|
|
104
|
+
status: 503,
|
|
105
|
+
kind: 'capacity',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const attempt = new AbortController();
|
|
109
|
+
const stop = () => attempt.abort();
|
|
110
|
+
controller.signal.addEventListener('abort', stop, { once: true });
|
|
111
|
+
let stalled = false;
|
|
112
|
+
let stall;
|
|
113
|
+
const arm = () => {
|
|
114
|
+
clearTimeout(stall);
|
|
115
|
+
stall = setTimeout(() => {
|
|
116
|
+
stalled = true;
|
|
117
|
+
attempt.abort();
|
|
118
|
+
}, this.stallMs);
|
|
119
|
+
};
|
|
120
|
+
let out;
|
|
121
|
+
try {
|
|
122
|
+
arm();
|
|
123
|
+
const stream = await this.fetch(`${this.baseUrl}/api/chat/stream/${job.jobId}`, {
|
|
124
|
+
headers: {
|
|
125
|
+
accept: 'text/event-stream',
|
|
126
|
+
...(this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {}),
|
|
127
|
+
},
|
|
128
|
+
signal: attempt.signal,
|
|
129
|
+
});
|
|
130
|
+
if (!stream.ok || !stream.body) {
|
|
131
|
+
throw new ProviderError('c0mpute', `could not read the job stream (${stream.status})`, {
|
|
132
|
+
status: stream.status || 502,
|
|
133
|
+
kind: 'capacity',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
out = await readStream(stream.body, arm);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (!stalled) throw err;
|
|
139
|
+
last = new ProviderError(
|
|
140
|
+
'c0mpute',
|
|
141
|
+
`${model ?? 'any'}: no answer for ${Math.round(this.stallMs / 1000)}s (job ${job.jobId})`,
|
|
142
|
+
{ status: 504, kind: 'capacity' },
|
|
143
|
+
);
|
|
144
|
+
continue;
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(stall);
|
|
147
|
+
controller.signal.removeEventListener('abort', stop);
|
|
148
|
+
}
|
|
149
|
+
const prompt = messages.reduce((n, m) => n + m.content.length, 0);
|
|
150
|
+
return {
|
|
151
|
+
text: out.text,
|
|
152
|
+
model: out.model ?? job.model ?? model ?? 'p2p',
|
|
153
|
+
usage: {
|
|
154
|
+
inputTokens: Math.ceil(prompt / 4),
|
|
155
|
+
outputTokens: Math.ceil(out.text.length / 4),
|
|
156
|
+
estimated: true,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
throw last ?? new ProviderError('c0mpute', 'no model configured', { kind: 'invalid' });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw toProviderError('c0mpute', err);
|
|
163
|
+
} finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
req.signal?.removeEventListener('abort', onAbort);
|
|
166
|
+
release();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function toMessages(req) {
|
|
172
|
+
if (req.messages?.length) {
|
|
173
|
+
const sys = req.system ? [{ role: 'system', content: req.system }] : [];
|
|
174
|
+
return [...sys, ...req.messages];
|
|
175
|
+
}
|
|
176
|
+
return [
|
|
177
|
+
...(req.system ? [{ role: 'system', content: req.system }] : []),
|
|
178
|
+
{ role: 'user', content: req.user ?? '' },
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Reads `meta`, `token` and `done` events until `done` or `error`. */
|
|
183
|
+
export async function readStream(body, onEvent) {
|
|
184
|
+
const reader = body.getReader();
|
|
185
|
+
const decoder = new TextDecoder();
|
|
186
|
+
let buffer = '';
|
|
187
|
+
let text = '';
|
|
188
|
+
let model;
|
|
189
|
+
for (;;) {
|
|
190
|
+
const { value, done } = await reader.read();
|
|
191
|
+
if (value?.length) onEvent?.();
|
|
192
|
+
if (value) buffer += decoder.decode(value, { stream: true });
|
|
193
|
+
buffer = buffer.replace(/\r\n/g, '\n');
|
|
194
|
+
let cut;
|
|
195
|
+
while ((cut = buffer.indexOf('\n\n')) >= 0) {
|
|
196
|
+
const block = buffer.slice(0, cut);
|
|
197
|
+
buffer = buffer.slice(cut + 2);
|
|
198
|
+
const event = /^event:\s*(.+)$/m.exec(block)?.[1]?.trim();
|
|
199
|
+
const raw = block
|
|
200
|
+
.split('\n')
|
|
201
|
+
.filter((l) => l.startsWith('data:'))
|
|
202
|
+
.map((l) => l.slice(5).trimStart())
|
|
203
|
+
.join('\n');
|
|
204
|
+
if (!event || !raw) continue;
|
|
205
|
+
let data;
|
|
206
|
+
try {
|
|
207
|
+
data = JSON.parse(raw);
|
|
208
|
+
} catch {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (event === 'meta' && typeof data.model === 'string') model = data.model;
|
|
212
|
+
else if (event === 'token' && typeof data.text === 'string') text += data.text;
|
|
213
|
+
else if (event === 'done') {
|
|
214
|
+
await reader.cancel().catch(() => {});
|
|
215
|
+
return { text: typeof data.text === 'string' && data.text ? data.text : text, model };
|
|
216
|
+
} else if (event === 'error') {
|
|
217
|
+
await reader.cancel().catch(() => {});
|
|
218
|
+
throw new ProviderError('c0mpute', String(data.message ?? data.error ?? 'job failed'), {
|
|
219
|
+
status: 502,
|
|
220
|
+
kind: 'capacity',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (done) break;
|
|
225
|
+
}
|
|
226
|
+
// No done event: the node or the connection died mid-answer.
|
|
227
|
+
throw new ProviderError(
|
|
228
|
+
'c0mpute',
|
|
229
|
+
text ? 'the stream ended before the answer finished' : 'the stream ended without an answer',
|
|
230
|
+
{ status: 502, kind: 'capacity' },
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
class Semaphore {
|
|
235
|
+
constructor(n) {
|
|
236
|
+
this.free = Math.max(1, n);
|
|
237
|
+
this.waiting = [];
|
|
238
|
+
}
|
|
239
|
+
acquire() {
|
|
240
|
+
if (this.free > 0) {
|
|
241
|
+
this.free--;
|
|
242
|
+
return Promise.resolve(() => this.#release());
|
|
243
|
+
}
|
|
244
|
+
return new Promise((resolve) => this.waiting.push(() => resolve(() => this.#release())));
|
|
245
|
+
}
|
|
246
|
+
#release() {
|
|
247
|
+
const next = this.waiting.shift();
|
|
248
|
+
if (next) next();
|
|
249
|
+
else this.free++;
|
|
250
|
+
}
|
|
251
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ai-providers status which providers are configured, in call order
|
|
4
|
+
* ai-providers ask "prompt" one call through the router; prints who answered
|
|
5
|
+
* ai-providers ask --json "..." same, asking for a JSON object
|
|
6
|
+
*/
|
|
7
|
+
import { createAiProviders } from './index.js';
|
|
8
|
+
|
|
9
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
10
|
+
const ai = createAiProviders();
|
|
11
|
+
|
|
12
|
+
if (cmd === 'status' || !cmd) {
|
|
13
|
+
const { order } = ai.plan();
|
|
14
|
+
console.log(JSON.stringify({ order: order.map((p) => p.name), providers: ai.status() }, null, 2));
|
|
15
|
+
} else if (cmd === 'ask') {
|
|
16
|
+
const json = rest[0] === '--json';
|
|
17
|
+
const prompt = (json ? rest.slice(1) : rest).join(' ');
|
|
18
|
+
if (!prompt) {
|
|
19
|
+
console.error('usage: ai-providers ask [--json] "prompt"');
|
|
20
|
+
process.exit(2);
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const r = json
|
|
24
|
+
? await ai.generateObject({ user: prompt, maxTokens: 800 })
|
|
25
|
+
: await ai.generateText({ user: prompt, maxTokens: 800 });
|
|
26
|
+
console.error(`# ${r.provider} ${r.model} in=${r.usage.inputTokens} out=${r.usage.outputTokens}`);
|
|
27
|
+
for (const a of r.attempts) console.error(`# skipped: ${a.error.message}`);
|
|
28
|
+
console.log(json ? JSON.stringify(r.object, null, 2) : r.text);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
console.error(err.message);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
console.error('usage: ai-providers status | ask [--json] "prompt"');
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One error type for every provider, carrying what the router needs to decide
|
|
3
|
+
* how long to leave a provider alone.
|
|
4
|
+
*
|
|
5
|
+
* kind:
|
|
6
|
+
* billing - out of credits, bad or revoked key. Nothing fixes it but a
|
|
7
|
+
* person, so the provider is parked for a long time.
|
|
8
|
+
* capacity - rate limit, no node free, 5xx, timeout. Try again soon.
|
|
9
|
+
* invalid - the request itself was wrong (400). Another provider might
|
|
10
|
+
* accept it, but this one should not be parked for it.
|
|
11
|
+
* output - the provider answered but the answer was unusable (empty,
|
|
12
|
+
* not JSON, failed the schema).
|
|
13
|
+
*/
|
|
14
|
+
export class ProviderError extends Error {
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} provider
|
|
17
|
+
* @param {string} message
|
|
18
|
+
* @param {{ status?: number, kind?: 'billing'|'capacity'|'invalid'|'output' }} [opts]
|
|
19
|
+
*/
|
|
20
|
+
constructor(provider, message, opts = {}) {
|
|
21
|
+
super(`${provider}: ${message}`);
|
|
22
|
+
this.name = 'ProviderError';
|
|
23
|
+
this.provider = provider;
|
|
24
|
+
this.status = opts.status ?? 0;
|
|
25
|
+
this.kind = opts.kind ?? classify(this.status, message);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const BILLING_TEXT =
|
|
30
|
+
/credit|billing|quota|insufficient|balance|payment|exceeded your current|no credits|plan/i;
|
|
31
|
+
|
|
32
|
+
/** Maps an HTTP status and message to a failure kind. */
|
|
33
|
+
export function classify(status, message = '') {
|
|
34
|
+
if (status === 401 || status === 402 || status === 403) return 'billing';
|
|
35
|
+
if (status === 429) return BILLING_TEXT.test(message) ? 'billing' : 'capacity';
|
|
36
|
+
if (status === 400 && BILLING_TEXT.test(message) && /credit|balance|billing/i.test(message)) {
|
|
37
|
+
// Anthropic reports an empty balance as a 400 invalid_request_error.
|
|
38
|
+
return 'billing';
|
|
39
|
+
}
|
|
40
|
+
if (status >= 400 && status < 500) return 'invalid';
|
|
41
|
+
return 'capacity';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Wraps anything thrown by a provider call into a ProviderError. */
|
|
45
|
+
export function toProviderError(provider, err) {
|
|
46
|
+
if (err instanceof ProviderError) return err;
|
|
47
|
+
const status = Number(err?.status ?? err?.statusCode ?? 0) || 0;
|
|
48
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
49
|
+
const kind = err?.name === 'AbortError' || err?.name === 'TimeoutError' ? 'capacity' : undefined;
|
|
50
|
+
return new ProviderError(provider, message, { status, ...(kind ? { kind } : {}) });
|
|
51
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @profullstack/ai-providers
|
|
3
|
+
*
|
|
4
|
+
* One way for every Profullstack app to call a text model:
|
|
5
|
+
* c0mpute first, always; OpenAI and Anthropic only as fallbacks, alternating
|
|
6
|
+
* so neither bill takes all the overflow; dead keys parked instead of retried.
|
|
7
|
+
*/
|
|
8
|
+
import { C0mputeProvider } from './c0mpute.js';
|
|
9
|
+
import { OpenAIProvider, AnthropicProvider } from './paid.js';
|
|
10
|
+
import { AiRouter } from './router.js';
|
|
11
|
+
|
|
12
|
+
export * from './errors.js';
|
|
13
|
+
export * from './c0mpute.js';
|
|
14
|
+
export * from './paid.js';
|
|
15
|
+
export * from './router.js';
|
|
16
|
+
export * from './json.js';
|
|
17
|
+
|
|
18
|
+
const OFF = /^(0|off|false|no|none|disabled?)$/i;
|
|
19
|
+
const list = (v) =>
|
|
20
|
+
String(v ?? '')
|
|
21
|
+
.split(',')
|
|
22
|
+
.map((s) => s.trim())
|
|
23
|
+
.filter(Boolean);
|
|
24
|
+
const num = (v) => (v == null || v === '' || Number.isNaN(Number(v)) ? undefined : Number(v));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds the router from the environment.
|
|
28
|
+
*
|
|
29
|
+
* C0MPUTE_API_KEY Infernet trusted-client key (ifc_...). Without it the
|
|
30
|
+
* public limit of 20 requests an hour per IP applies.
|
|
31
|
+
* C0MPUTE_URL default https://infernetprotocol.com
|
|
32
|
+
* C0MPUTE_MODELS comma list, tried in order (C0MPUTE_MODEL also read)
|
|
33
|
+
* C0MPUTE_ANY_MODEL 1 to let any free node answer as a last resort
|
|
34
|
+
* C0MPUTE_CONCURRENCY jobs in flight per process, default 2
|
|
35
|
+
* C0MPUTE_TIMEOUT_MS whole job, default 300000
|
|
36
|
+
* C0MPUTE_STALL_MS give up on a model whose job streams nothing this long, default 90000
|
|
37
|
+
* C0MPUTE_DISABLED 1 to skip c0mpute
|
|
38
|
+
* OPENAI_API_KEY, AI_OPENAI_MODEL
|
|
39
|
+
* ANTHROPIC_API_KEY, AI_ANTHROPIC_MODEL
|
|
40
|
+
* AI_PAID_FALLBACK 0/off to never fall back to a paid provider
|
|
41
|
+
*
|
|
42
|
+
* @param {object} [opts]
|
|
43
|
+
* @param {Record<string, string|undefined>} [opts.env]
|
|
44
|
+
* @param {object} [opts.c0mpute] overrides for C0mputeProvider
|
|
45
|
+
* @param {object} [opts.openai] overrides for OpenAIProvider
|
|
46
|
+
* @param {object} [opts.anthropic] overrides for AnthropicProvider
|
|
47
|
+
* @param {boolean} [opts.paidFallback]
|
|
48
|
+
* @param {object} [opts.router] overrides for AiRouter
|
|
49
|
+
*/
|
|
50
|
+
export function createAiProviders(opts = {}) {
|
|
51
|
+
const env = opts.env ?? (typeof process !== 'undefined' ? process.env : {});
|
|
52
|
+
const primary = [];
|
|
53
|
+
const fallback = [];
|
|
54
|
+
|
|
55
|
+
if (!/^(1|true|yes|on)$/i.test(env.C0MPUTE_DISABLED ?? '')) {
|
|
56
|
+
const models = list(env.C0MPUTE_MODELS || env.C0MPUTE_MODEL);
|
|
57
|
+
primary.push(
|
|
58
|
+
new C0mputeProvider({
|
|
59
|
+
apiKey: env.C0MPUTE_API_KEY || env.INFERNET_API_KEY,
|
|
60
|
+
baseUrl: env.C0MPUTE_URL,
|
|
61
|
+
...(models.length ? { models } : {}),
|
|
62
|
+
anyModel: /^(1|true|yes)$/i.test(env.C0MPUTE_ANY_MODEL ?? ''),
|
|
63
|
+
concurrency: num(env.C0MPUTE_CONCURRENCY),
|
|
64
|
+
timeoutMs: num(env.C0MPUTE_TIMEOUT_MS),
|
|
65
|
+
stallMs: num(env.C0MPUTE_STALL_MS),
|
|
66
|
+
...opts.c0mpute,
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const paid = opts.paidFallback ?? !OFF.test(env.AI_PAID_FALLBACK ?? '');
|
|
72
|
+
if (paid) {
|
|
73
|
+
const openaiKey = opts.openai?.apiKey ?? env.OPENAI_API_KEY;
|
|
74
|
+
if (openaiKey) {
|
|
75
|
+
fallback.push(
|
|
76
|
+
new OpenAIProvider({ model: env.AI_OPENAI_MODEL, ...opts.openai, apiKey: openaiKey }),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const anthropicKey = opts.anthropic?.apiKey ?? env.ANTHROPIC_API_KEY;
|
|
80
|
+
if (anthropicKey) {
|
|
81
|
+
fallback.push(
|
|
82
|
+
new AnthropicProvider({ model: env.AI_ANTHROPIC_MODEL, ...opts.anthropic, apiKey: anthropicKey }),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return new AiRouter({ ...opts.router, primary, fallback });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let shared;
|
|
91
|
+
/** One router per process, so the breaker and rotation see every call. */
|
|
92
|
+
export function getAi() {
|
|
93
|
+
shared ??= createAiProviders();
|
|
94
|
+
return shared;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Shorthand for getAi().generateText(req). */
|
|
98
|
+
export const generateText = (req) => getAi().generateText(req);
|
|
99
|
+
/** Shorthand for getAi().generateObject(req). */
|
|
100
|
+
export const generateObject = (req) => getAi().generateObject(req);
|
package/src/json.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting JSON out of a model that was asked for JSON.
|
|
3
|
+
*
|
|
4
|
+
* Small open models wrap it in fences, add a sentence before it, or think out
|
|
5
|
+
* loud in <think> tags first. This takes the first complete top-level JSON
|
|
6
|
+
* object or array in the text.
|
|
7
|
+
*/
|
|
8
|
+
export function extractJson(text) {
|
|
9
|
+
if (typeof text !== 'string') throw new Error('no text');
|
|
10
|
+
let s = text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
|
11
|
+
const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(s);
|
|
12
|
+
if (fence) s = fence[1].trim();
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(s);
|
|
15
|
+
} catch {}
|
|
16
|
+
const start = s.search(/[[{]/);
|
|
17
|
+
if (start < 0) throw new Error('no JSON in the answer');
|
|
18
|
+
const open = s[start];
|
|
19
|
+
const close = open === '{' ? '}' : ']';
|
|
20
|
+
let depth = 0;
|
|
21
|
+
let inString = false;
|
|
22
|
+
let escaped = false;
|
|
23
|
+
for (let i = start; i < s.length; i++) {
|
|
24
|
+
const c = s[i];
|
|
25
|
+
if (inString) {
|
|
26
|
+
if (escaped) escaped = false;
|
|
27
|
+
else if (c === '\\') escaped = true;
|
|
28
|
+
else if (c === '"') inString = false;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (c === '"') inString = true;
|
|
32
|
+
else if (c === open) depth++;
|
|
33
|
+
else if (c === close && --depth === 0) {
|
|
34
|
+
return JSON.parse(s.slice(start, i + 1));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
throw new Error('the JSON in the answer is incomplete');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validates with whatever the caller passed: a zod schema (safeParse/parse),
|
|
42
|
+
* a plain function that returns the value or throws, or nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function validate(schema, value) {
|
|
45
|
+
if (!schema) return value;
|
|
46
|
+
if (typeof schema.safeParse === 'function') {
|
|
47
|
+
const r = schema.safeParse(value);
|
|
48
|
+
if (r.success) return r.data;
|
|
49
|
+
const issues = (r.error?.issues ?? [])
|
|
50
|
+
.slice(0, 8)
|
|
51
|
+
.map((i) => `${(i.path ?? []).join('.') || '(root)'}: ${i.message}`)
|
|
52
|
+
.join('; ');
|
|
53
|
+
throw new Error(`does not match the schema: ${issues || r.error?.message || 'invalid'}`);
|
|
54
|
+
}
|
|
55
|
+
if (typeof schema.parse === 'function') return schema.parse(value);
|
|
56
|
+
if (typeof schema === 'function') return schema(value);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function jsonInstructions(name, jsonSchema) {
|
|
61
|
+
return [
|
|
62
|
+
`Return only one JSON object${name ? ` (${name})` : ''}. No Markdown, no code fences, no commentary.`,
|
|
63
|
+
...(jsonSchema ? ['It must match this JSON schema:', JSON.stringify(jsonSchema)] : []),
|
|
64
|
+
].join('\n');
|
|
65
|
+
}
|
package/src/paid.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paid fallbacks, over plain fetch so the package has no SDK dependency.
|
|
3
|
+
*
|
|
4
|
+
* OpenAI: Chat Completions. Anthropic: Messages. Both return real token counts.
|
|
5
|
+
*/
|
|
6
|
+
import { ProviderError, toProviderError } from './errors.js';
|
|
7
|
+
import { toMessages } from './c0mpute.js';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_OPENAI_MODEL = 'gpt-5-mini';
|
|
10
|
+
export const DEFAULT_ANTHROPIC_MODEL = 'claude-haiku-5-5';
|
|
11
|
+
|
|
12
|
+
async function failure(provider, res) {
|
|
13
|
+
const body = await res.text().catch(() => '');
|
|
14
|
+
let message = body.slice(0, 500);
|
|
15
|
+
try {
|
|
16
|
+
const j = JSON.parse(body);
|
|
17
|
+
message = j?.error?.message ?? j?.message ?? message;
|
|
18
|
+
} catch {}
|
|
19
|
+
return new ProviderError(provider, `${res.status} ${message}`.trim(), { status: res.status });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function withTimeout(req, ms) {
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timer = setTimeout(() => controller.abort(), req.timeoutMs ?? ms);
|
|
25
|
+
const onAbort = () => controller.abort();
|
|
26
|
+
req.signal?.addEventListener('abort', onAbort, { once: true });
|
|
27
|
+
return {
|
|
28
|
+
signal: controller.signal,
|
|
29
|
+
done() {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
req.signal?.removeEventListener('abort', onAbort);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class OpenAIProvider {
|
|
37
|
+
constructor(opts = {}) {
|
|
38
|
+
this.name = 'openai';
|
|
39
|
+
this.apiKey = opts.apiKey;
|
|
40
|
+
this.baseUrl = (opts.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
41
|
+
this.model = opts.model || DEFAULT_OPENAI_MODEL;
|
|
42
|
+
this.timeoutMs = opts.timeoutMs ?? 120_000;
|
|
43
|
+
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async generate(req) {
|
|
47
|
+
const t = withTimeout(req, this.timeoutMs);
|
|
48
|
+
try {
|
|
49
|
+
const model = req.models?.openai || this.model;
|
|
50
|
+
const res = await this.fetch(`${this.baseUrl}/chat/completions`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${this.apiKey}` },
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
model,
|
|
55
|
+
messages: toMessages(req),
|
|
56
|
+
max_completion_tokens: req.maxTokens ?? 1024,
|
|
57
|
+
...(req.json ? { response_format: { type: 'json_object' } } : {}),
|
|
58
|
+
}),
|
|
59
|
+
signal: t.signal,
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok) throw await failure('openai', res);
|
|
62
|
+
const j = await res.json();
|
|
63
|
+
return {
|
|
64
|
+
text: j.choices?.[0]?.message?.content ?? '',
|
|
65
|
+
model: j.model ?? model,
|
|
66
|
+
usage: {
|
|
67
|
+
inputTokens: j.usage?.prompt_tokens ?? 0,
|
|
68
|
+
outputTokens: j.usage?.completion_tokens ?? 0,
|
|
69
|
+
estimated: false,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw toProviderError('openai', err);
|
|
74
|
+
} finally {
|
|
75
|
+
t.done();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class AnthropicProvider {
|
|
81
|
+
constructor(opts = {}) {
|
|
82
|
+
this.name = 'anthropic';
|
|
83
|
+
this.apiKey = opts.apiKey;
|
|
84
|
+
this.baseUrl = (opts.baseUrl || 'https://api.anthropic.com/v1').replace(/\/+$/, '');
|
|
85
|
+
this.model = opts.model || DEFAULT_ANTHROPIC_MODEL;
|
|
86
|
+
this.timeoutMs = opts.timeoutMs ?? 120_000;
|
|
87
|
+
this.fetch = opts.fetch ?? globalThis.fetch;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async generate(req) {
|
|
91
|
+
const t = withTimeout(req, this.timeoutMs);
|
|
92
|
+
try {
|
|
93
|
+
const model = req.models?.anthropic || this.model;
|
|
94
|
+
const all = toMessages(req);
|
|
95
|
+
const system = all.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n');
|
|
96
|
+
const messages = all.filter((m) => m.role !== 'system');
|
|
97
|
+
const res = await this.fetch(`${this.baseUrl}/messages`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
'content-type': 'application/json',
|
|
101
|
+
'x-api-key': this.apiKey,
|
|
102
|
+
'anthropic-version': '2023-06-01',
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
model,
|
|
106
|
+
max_tokens: req.maxTokens ?? 1024,
|
|
107
|
+
...(system ? { system } : {}),
|
|
108
|
+
messages,
|
|
109
|
+
}),
|
|
110
|
+
signal: t.signal,
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok) throw await failure('anthropic', res);
|
|
113
|
+
const j = await res.json();
|
|
114
|
+
const text = (j.content ?? [])
|
|
115
|
+
.filter((b) => b.type === 'text')
|
|
116
|
+
.map((b) => b.text)
|
|
117
|
+
.join('');
|
|
118
|
+
return {
|
|
119
|
+
text,
|
|
120
|
+
model: j.model ?? model,
|
|
121
|
+
usage: {
|
|
122
|
+
inputTokens: j.usage?.input_tokens ?? 0,
|
|
123
|
+
outputTokens: j.usage?.output_tokens ?? 0,
|
|
124
|
+
estimated: false,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
} catch (err) {
|
|
128
|
+
throw toProviderError('anthropic', err);
|
|
129
|
+
} finally {
|
|
130
|
+
t.done();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/router.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The order every call takes:
|
|
3
|
+
*
|
|
4
|
+
* 1. primary providers in order (c0mpute)
|
|
5
|
+
* 2. fallback providers, rotated one step per call so the paid ones share
|
|
6
|
+
* the load evenly: call 1 tries openai then anthropic, call 2 tries
|
|
7
|
+
* anthropic then openai, and so on
|
|
8
|
+
*
|
|
9
|
+
* A provider that fails is parked so the next calls do not pay its latency:
|
|
10
|
+
* billing (no credits, bad key) parked `billingMs`, default 1 hour
|
|
11
|
+
* capacity (429, 5xx, no node, timeout) parked 30 s, doubling to 10 min
|
|
12
|
+
* invalid / output not parked
|
|
13
|
+
* A success clears it.
|
|
14
|
+
*/
|
|
15
|
+
import { ProviderError, toProviderError } from './errors.js';
|
|
16
|
+
import { extractJson, validate, jsonInstructions } from './json.js';
|
|
17
|
+
|
|
18
|
+
export class AllProvidersFailedError extends Error {
|
|
19
|
+
constructor(attempts, skipped = []) {
|
|
20
|
+
const parts = attempts.map((a) => a.error.message);
|
|
21
|
+
for (const s of skipped) parts.push(`${s.name}: parked ${Math.ceil(s.waitMs / 1000)}s after ${s.reason}`);
|
|
22
|
+
super(`AI generation failed (${parts.join('; ') || 'no provider configured'})`);
|
|
23
|
+
this.name = 'AllProvidersFailedError';
|
|
24
|
+
this.attempts = attempts;
|
|
25
|
+
this.skipped = skipped;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class AiRouter {
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} opts
|
|
32
|
+
* @param {Array<{name:string, generate?:Function}>} [opts.primary]
|
|
33
|
+
* @param {Array<{name:string, generate?:Function}>} [opts.fallback]
|
|
34
|
+
* @param {boolean} [opts.rotate]
|
|
35
|
+
* @param {number} [opts.billingMs]
|
|
36
|
+
* @param {number} [opts.capacityMs]
|
|
37
|
+
* @param {number} [opts.capacityMaxMs]
|
|
38
|
+
* @param {(e: object) => void} [opts.onAttempt]
|
|
39
|
+
* @param {() => number} [opts.now]
|
|
40
|
+
*/
|
|
41
|
+
constructor(opts = {}) {
|
|
42
|
+
this.primary = opts.primary ?? [];
|
|
43
|
+
this.fallback = opts.fallback ?? [];
|
|
44
|
+
this.rotate = opts.rotate ?? true;
|
|
45
|
+
this.billingMs = opts.billingMs ?? 60 * 60_000;
|
|
46
|
+
this.capacityMs = opts.capacityMs ?? 30_000;
|
|
47
|
+
this.capacityMaxMs = opts.capacityMaxMs ?? 10 * 60_000;
|
|
48
|
+
this.onAttempt = opts.onAttempt;
|
|
49
|
+
this.now = opts.now ?? Date.now;
|
|
50
|
+
this.turn = 0;
|
|
51
|
+
/** @type {Map<string, {until:number, strikes:number, reason:string}>} */
|
|
52
|
+
this.parked = new Map();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get providers() {
|
|
56
|
+
return [...this.primary, ...this.fallback];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The providers the next call would try, in order, and the ones skipped. */
|
|
60
|
+
plan() {
|
|
61
|
+
const n = this.fallback.length;
|
|
62
|
+
const shift = this.rotate && n > 1 ? this.turn % n : 0;
|
|
63
|
+
const rotated = [...this.fallback.slice(shift), ...this.fallback.slice(0, shift)];
|
|
64
|
+
const now = this.now();
|
|
65
|
+
const order = [];
|
|
66
|
+
const skipped = [];
|
|
67
|
+
for (const p of [...this.primary, ...rotated]) {
|
|
68
|
+
const park = this.parked.get(p.name);
|
|
69
|
+
if (park && park.until > now) skipped.push({ name: p.name, waitMs: park.until - now, reason: park.reason });
|
|
70
|
+
else order.push(p);
|
|
71
|
+
}
|
|
72
|
+
return { order, skipped };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
status() {
|
|
76
|
+
const now = this.now();
|
|
77
|
+
return this.providers.map((p) => {
|
|
78
|
+
const park = this.parked.get(p.name);
|
|
79
|
+
const active = park && park.until > now;
|
|
80
|
+
return {
|
|
81
|
+
name: p.name,
|
|
82
|
+
tier: this.primary.includes(p) ? 'primary' : 'fallback',
|
|
83
|
+
available: !active,
|
|
84
|
+
...(active ? { parkedForMs: park.until - now, reason: park.reason } : {}),
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#fail(name, err) {
|
|
90
|
+
if (err.kind !== 'billing' && err.kind !== 'capacity') return;
|
|
91
|
+
const prev = this.parked.get(name);
|
|
92
|
+
const strikes = (prev?.strikes ?? 0) + 1;
|
|
93
|
+
const ms =
|
|
94
|
+
err.kind === 'billing'
|
|
95
|
+
? this.billingMs
|
|
96
|
+
: Math.min(this.capacityMaxMs, this.capacityMs * 2 ** (strikes - 1));
|
|
97
|
+
this.parked.set(name, { until: this.now() + ms, strikes, reason: err.kind });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Runs `task(provider)` on each provider in order until one returns.
|
|
102
|
+
* The building block: generateText/generateObject use it, and an app with
|
|
103
|
+
* its own provider code (structured outputs, prompt caching) can too.
|
|
104
|
+
*
|
|
105
|
+
* @template T
|
|
106
|
+
* @param {(provider: any) => Promise<T>} task
|
|
107
|
+
* @param {{ feature?: string, only?: string[] }} [opts]
|
|
108
|
+
* @returns {Promise<{ provider: string, value: T, attempts: object[] }>}
|
|
109
|
+
*/
|
|
110
|
+
async run(task, opts = {}) {
|
|
111
|
+
const { order, skipped } = this.plan();
|
|
112
|
+
this.turn++;
|
|
113
|
+
const attempts = [];
|
|
114
|
+
for (const provider of order) {
|
|
115
|
+
if (opts.only && !opts.only.includes(provider.name)) continue;
|
|
116
|
+
const started = this.now();
|
|
117
|
+
try {
|
|
118
|
+
const value = await task(provider);
|
|
119
|
+
this.parked.delete(provider.name);
|
|
120
|
+
this.onAttempt?.({ provider: provider.name, feature: opts.feature, ok: true, ms: this.now() - started });
|
|
121
|
+
return { provider: provider.name, value, attempts };
|
|
122
|
+
} catch (raw) {
|
|
123
|
+
const error = toProviderError(provider.name, raw);
|
|
124
|
+
this.#fail(provider.name, error);
|
|
125
|
+
attempts.push({ provider: provider.name, error });
|
|
126
|
+
this.onAttempt?.({
|
|
127
|
+
provider: provider.name,
|
|
128
|
+
feature: opts.feature,
|
|
129
|
+
ok: false,
|
|
130
|
+
ms: this.now() - started,
|
|
131
|
+
kind: error.kind,
|
|
132
|
+
error: error.message,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
throw new AllProvidersFailedError(attempts, skipped);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {import('../index.js').TextRequest} req
|
|
141
|
+
*/
|
|
142
|
+
async generateText(req) {
|
|
143
|
+
const { provider, value, attempts } = await this.run(async (p) => {
|
|
144
|
+
const out = await p.generate(req);
|
|
145
|
+
if (!out.text?.trim()) throw new ProviderError(p.name, 'empty answer', { kind: 'output' });
|
|
146
|
+
return out;
|
|
147
|
+
}, req);
|
|
148
|
+
return { ...value, provider, attempts };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Asks for JSON, extracts it, validates it against `schema`. An unusable
|
|
153
|
+
* answer gets one repair attempt on the same provider before moving on.
|
|
154
|
+
*
|
|
155
|
+
* @param {import('../index.js').ObjectRequest} req
|
|
156
|
+
*/
|
|
157
|
+
async generateObject(req) {
|
|
158
|
+
const { provider, value, attempts } = await this.run((p) => generateObjectOn(p, req), req);
|
|
159
|
+
return { ...value, provider, attempts };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function generateObjectOn(provider, req) {
|
|
164
|
+
const system = [req.system, jsonInstructions(req.name, req.jsonSchema)].filter(Boolean).join('\n\n');
|
|
165
|
+
const base = { ...req, system, json: true };
|
|
166
|
+
let out = await provider.generate(base);
|
|
167
|
+
let object;
|
|
168
|
+
try {
|
|
169
|
+
object = validate(req.schema, extractJson(out.text));
|
|
170
|
+
} catch (first) {
|
|
171
|
+
const repairs = req.repairs ?? 1;
|
|
172
|
+
if (repairs < 1) throw new ProviderError(provider.name, first.message, { kind: 'output' });
|
|
173
|
+
const user = req.user ?? req.messages?.at(-1)?.content ?? '';
|
|
174
|
+
out = await provider.generate({
|
|
175
|
+
...base,
|
|
176
|
+
messages: undefined,
|
|
177
|
+
user: [
|
|
178
|
+
user,
|
|
179
|
+
'',
|
|
180
|
+
'Your previous answer could not be used:',
|
|
181
|
+
first.message,
|
|
182
|
+
'',
|
|
183
|
+
'Previous answer:',
|
|
184
|
+
String(out.text).slice(0, 4000),
|
|
185
|
+
'',
|
|
186
|
+
'Answer again with only the corrected JSON object.',
|
|
187
|
+
].join('\n'),
|
|
188
|
+
});
|
|
189
|
+
try {
|
|
190
|
+
object = validate(req.schema, extractJson(out.text));
|
|
191
|
+
} catch (second) {
|
|
192
|
+
throw new ProviderError(provider.name, second.message, { kind: 'output' });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { object, text: out.text, model: out.model, usage: out.usage };
|
|
196
|
+
}
|