pyyol 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +24 -0
- package/dist/instrument.d.ts +7 -3
- package/dist/instrument.js +97 -7
- package/dist/pricing.d.ts +10 -4
- package/dist/pricing.js +43 -6
- package/dist/providers.d.ts +27 -0
- package/dist/providers.js +140 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/rules/llms-full.txt +49 -1
package/dist/cli.js
CHANGED
|
@@ -636,7 +636,31 @@ async function cmdWallet(a) {
|
|
|
636
636
|
console.error(`${BAD} not logged in — run \`pyyol login\` first.`);
|
|
637
637
|
return 2;
|
|
638
638
|
}
|
|
639
|
+
// AN AGENT KEY CANNOT READ THE OWNER'S TREASURY, and must not be sent here.
|
|
640
|
+
//
|
|
641
|
+
// The fallback chain above ends at PYYOL_TOKEN, which the deployment docs define as
|
|
642
|
+
// the sk_arena_… AGENT key for CI and containers. That key resolves server-side to
|
|
643
|
+
// its owner's user id, so this command used to work with it — an agent credential
|
|
644
|
+
// reading its owner's full balance and ledger. The arena now requires user scope on
|
|
645
|
+
// /v1/user/wallet and answers 403, which would surface here as an opaque
|
|
646
|
+
// "could not fetch wallet (403)".
|
|
647
|
+
//
|
|
648
|
+
// Refusing locally, by shape, is better than relaying that: it names the credential
|
|
649
|
+
// actually in play (easy to miss when it arrives from the environment rather than a
|
|
650
|
+
// flag) and says which one the command needs.
|
|
651
|
+
if (token.startsWith(AGENT_KEY_PREFIX)) {
|
|
652
|
+
console.error(`${BAD} \`pyyol wallet\` shows the OWNER's treasury, so it needs your dashboard login — ` +
|
|
653
|
+
`not an agent key.\n` +
|
|
654
|
+
` The token in use is an agent key (sk_arena_…), probably from $PYYOL_TOKEN.\n` +
|
|
655
|
+
` Run \`pyyol login\` on this machine, or unset PYYOL_TOKEN for this command.`);
|
|
656
|
+
return 2;
|
|
657
|
+
}
|
|
639
658
|
const [st, w] = await apiGet(`${base}/v1/user/wallet`, token);
|
|
659
|
+
if (st === 403) {
|
|
660
|
+
console.error(`${BAD} this credential is not allowed to read the owner's treasury. ` +
|
|
661
|
+
`Sign in with \`pyyol login\` and try again.`);
|
|
662
|
+
return 1;
|
|
663
|
+
}
|
|
640
664
|
if (st !== 200) {
|
|
641
665
|
console.error(`${BAD} could not fetch wallet (${st}): ${JSON.stringify(w)}`);
|
|
642
666
|
return 1;
|
package/dist/instrument.d.ts
CHANGED
|
@@ -6,6 +6,13 @@ export declare function disableGateway(): void;
|
|
|
6
6
|
export declare function gatewayBaseUrl(provider: string): string;
|
|
7
7
|
/** The X-Pyyol-* identity headers for the current turn (empty if routing off). */
|
|
8
8
|
export declare function gatewayHeaders(): Record<string, string>;
|
|
9
|
+
/** The provider for one instrumented call.
|
|
10
|
+
*
|
|
11
|
+
* `patchedAs` is the SDK we wrapped (which wire format this is). The client's baseURL
|
|
12
|
+
* is consulted first and wins. A baseURL pointing at the PYYOL GATEWAY is not used for
|
|
13
|
+
* attribution — it says the call was proxied, not who served it — so the upstream is
|
|
14
|
+
* recovered from the gateway path by matching it back against PROVIDER_PATH. */
|
|
15
|
+
export declare function resolveCallProvider(resource: Any, patchedAs: string): string;
|
|
9
16
|
/** Point a provider client at the Pyyol Gateway (sets its baseURL). Explicit, robust
|
|
10
17
|
* opt-in that operates on the given instance. Returns the client. No-op when routing
|
|
11
18
|
* is off or the provider can't be determined. */
|
|
@@ -18,9 +25,6 @@ export interface ExtractedUsage {
|
|
|
18
25
|
cachedTokens: number;
|
|
19
26
|
reasoningTokens: number;
|
|
20
27
|
}
|
|
21
|
-
/** Pull normalized usage from a provider response, or null if it has none.
|
|
22
|
-
* Handles OpenAI Chat Completions, Anthropic Messages, and the OpenAI Responses
|
|
23
|
-
* API; duck-typed so a plain object or an SDK object both work. */
|
|
24
28
|
export declare function extractUsage(resp: Any): ExtractedUsage | null;
|
|
25
29
|
/** Record usage from a provider response: compute cost, add to the turn
|
|
26
30
|
* accumulator, and emit a Lens model_call span. Returns the extracted usage (or
|
package/dist/instrument.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// pass `stream_options: { include_usage: true }` and record manually, or use
|
|
17
17
|
// non-streaming calls for automatic capture.
|
|
18
18
|
import { estimateCost } from "./pricing.js";
|
|
19
|
+
import * as providers from "./providers.js";
|
|
19
20
|
import { currentSpan, currentUsage } from "./telemetry.js";
|
|
20
21
|
// [prototype, method, original] for uninstrument().
|
|
21
22
|
const PATCHED = [];
|
|
@@ -54,11 +55,15 @@ export function gatewayHeaders() {
|
|
|
54
55
|
return h;
|
|
55
56
|
}
|
|
56
57
|
function detectProvider(client) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// baseURL first: most of the ecosystem speaks the OpenAI wire format, so an OpenAI
|
|
59
|
+
// client pointed at http://localhost:11434/v1 IS Ollama — and calling it "openai"
|
|
60
|
+
// would price a model on the developer's own GPU at OpenAI's rates.
|
|
61
|
+
const byUrl = providers.fromBaseUrl(String(client?.baseURL ?? ""));
|
|
62
|
+
if (byUrl)
|
|
63
|
+
return byUrl;
|
|
64
|
+
const byName = providers.fromName(client?.constructor?.name ?? "");
|
|
65
|
+
if (byName)
|
|
66
|
+
return byName;
|
|
62
67
|
// duck-type fallback
|
|
63
68
|
if (client?.chat?.completions)
|
|
64
69
|
return "openai";
|
|
@@ -66,6 +71,24 @@ function detectProvider(client) {
|
|
|
66
71
|
return "anthropic";
|
|
67
72
|
return "";
|
|
68
73
|
}
|
|
74
|
+
/** The provider for one instrumented call.
|
|
75
|
+
*
|
|
76
|
+
* `patchedAs` is the SDK we wrapped (which wire format this is). The client's baseURL
|
|
77
|
+
* is consulted first and wins. A baseURL pointing at the PYYOL GATEWAY is not used for
|
|
78
|
+
* attribution — it says the call was proxied, not who served it — so the upstream is
|
|
79
|
+
* recovered from the gateway path by matching it back against PROVIDER_PATH. */
|
|
80
|
+
export function resolveCallProvider(resource, patchedAs) {
|
|
81
|
+
const base = clientBaseUrl(resource);
|
|
82
|
+
if (base && gateway.base && base.startsWith(gateway.base)) {
|
|
83
|
+
const tail = base.slice(gateway.base.length);
|
|
84
|
+
for (const [provider, path] of Object.entries(PROVIDER_PATH)) {
|
|
85
|
+
if (tail.startsWith(path))
|
|
86
|
+
return provider;
|
|
87
|
+
}
|
|
88
|
+
return patchedAs;
|
|
89
|
+
}
|
|
90
|
+
return providers.resolve({ baseUrl: base, fallback: patchedAs });
|
|
91
|
+
}
|
|
69
92
|
/** Point a provider client at the Pyyol Gateway (sets its baseURL). Explicit, robust
|
|
70
93
|
* opt-in that operates on the given instance. Returns the client. No-op when routing
|
|
71
94
|
* is off or the provider can't be determined. */
|
|
@@ -130,10 +153,71 @@ function get(obj, name, dflt) {
|
|
|
130
153
|
/** Pull normalized usage from a provider response, or null if it has none.
|
|
131
154
|
* Handles OpenAI Chat Completions, Anthropic Messages, and the OpenAI Responses
|
|
132
155
|
* API; duck-typed so a plain object or an SDK object both work. */
|
|
156
|
+
/** Ollama's native shape: no `usage` object at all, counts at the top level.
|
|
157
|
+
* Without this an agent running Ollama reported zero tokens forever — it looked
|
|
158
|
+
* instrumented and measured nothing, and no bill ever arrives to contradict a zero. */
|
|
159
|
+
function extractOllama(resp) {
|
|
160
|
+
const prompt = get(resp, "prompt_eval_count");
|
|
161
|
+
const completion = get(resp, "eval_count");
|
|
162
|
+
if (prompt === undefined && completion === undefined)
|
|
163
|
+
return null;
|
|
164
|
+
return {
|
|
165
|
+
model: get(resp, "model", "") || "",
|
|
166
|
+
provider: providers.OLLAMA,
|
|
167
|
+
promptTokens: Math.trunc(prompt || 0),
|
|
168
|
+
completionTokens: Math.trunc(completion || 0),
|
|
169
|
+
cachedTokens: 0,
|
|
170
|
+
reasoningTokens: 0,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/** Google Gemini: counts hang off `usageMetadata` (camelCase in the JS SDK). */
|
|
174
|
+
function extractGoogle(resp) {
|
|
175
|
+
const um = get(resp, "usageMetadata") ?? get(resp, "usage_metadata");
|
|
176
|
+
if (um == null)
|
|
177
|
+
return null;
|
|
178
|
+
const prompt = get(um, "promptTokenCount", get(um, "prompt_token_count", 0)) || 0;
|
|
179
|
+
const completion = get(um, "candidatesTokenCount", get(um, "candidates_token_count", 0)) || 0;
|
|
180
|
+
if (!prompt && !completion)
|
|
181
|
+
return null;
|
|
182
|
+
return {
|
|
183
|
+
model: get(resp, "modelVersion", "") || get(resp, "model", "") || "",
|
|
184
|
+
provider: providers.GOOGLE,
|
|
185
|
+
promptTokens: Math.trunc(prompt),
|
|
186
|
+
completionTokens: Math.trunc(completion),
|
|
187
|
+
cachedTokens: Math.trunc(get(um, "cachedContentTokenCount", get(um, "cached_content_token_count", 0)) || 0),
|
|
188
|
+
reasoningTokens: Math.trunc(get(um, "thoughtsTokenCount", get(um, "thoughts_token_count", 0)) || 0),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/** Cohere nests counts under `meta.tokens`. */
|
|
192
|
+
function extractCohere(resp) {
|
|
193
|
+
const tokens = get(get(resp, "meta"), "tokens");
|
|
194
|
+
if (tokens == null)
|
|
195
|
+
return null;
|
|
196
|
+
const prompt = get(tokens, "inputTokens", get(tokens, "input_tokens", 0)) || 0;
|
|
197
|
+
const completion = get(tokens, "outputTokens", get(tokens, "output_tokens", 0)) || 0;
|
|
198
|
+
if (!prompt && !completion)
|
|
199
|
+
return null;
|
|
200
|
+
return {
|
|
201
|
+
model: get(resp, "model", "") || "",
|
|
202
|
+
provider: "cohere",
|
|
203
|
+
promptTokens: Math.trunc(prompt),
|
|
204
|
+
completionTokens: Math.trunc(completion),
|
|
205
|
+
cachedTokens: 0,
|
|
206
|
+
reasoningTokens: 0,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
133
209
|
export function extractUsage(resp) {
|
|
134
210
|
const u = get(resp, "usage");
|
|
135
|
-
if (u == null)
|
|
211
|
+
if (u == null) {
|
|
212
|
+
// Shapes that carry no `usage` at all. Only consulted here, so they can never
|
|
213
|
+
// hijack a normal OpenAI or Anthropic response.
|
|
214
|
+
for (const extractor of [extractOllama, extractGoogle, extractCohere]) {
|
|
215
|
+
const info = extractor(resp);
|
|
216
|
+
if (info !== null)
|
|
217
|
+
return info;
|
|
218
|
+
}
|
|
136
219
|
return null;
|
|
220
|
+
}
|
|
137
221
|
const model = get(resp, "model", "") || "";
|
|
138
222
|
let prompt = get(u, "prompt_tokens");
|
|
139
223
|
let completion = get(u, "completion_tokens");
|
|
@@ -175,6 +259,10 @@ export function recordResponse(resp, o = {}) {
|
|
|
175
259
|
return null;
|
|
176
260
|
const provider = o.provider || info.provider;
|
|
177
261
|
const cost = estimateCost(info.model, {
|
|
262
|
+
// WHO served it, not just what was served: an open-weight model is free on your
|
|
263
|
+
// own hardware and billed when a hosted provider serves it, and the model id is
|
|
264
|
+
// identical either way.
|
|
265
|
+
provider,
|
|
178
266
|
promptTokens: info.promptTokens,
|
|
179
267
|
completionTokens: info.completionTokens,
|
|
180
268
|
cachedTokens: info.cachedTokens,
|
|
@@ -213,7 +301,9 @@ export function patchPrototype(proto, method, provider) {
|
|
|
213
301
|
const start = Date.now();
|
|
214
302
|
const resp = await orig.apply(this, args);
|
|
215
303
|
try {
|
|
216
|
-
|
|
304
|
+
// Resolve from the CLIENT's baseURL — the wire format we patched is not the
|
|
305
|
+
// same thing as who actually served the call.
|
|
306
|
+
recordResponse(resp, { provider: resolveCallProvider(this, provider), latencyMs: Date.now() - start });
|
|
217
307
|
}
|
|
218
308
|
catch {
|
|
219
309
|
// instrumentation must never break the dev's call
|
package/dist/pricing.d.ts
CHANGED
|
@@ -7,10 +7,13 @@ export interface Rate {
|
|
|
7
7
|
/** USD per 1M cached (prompt-cache read) input tokens; defaults to `input`. */
|
|
8
8
|
cachedInput?: number;
|
|
9
9
|
}
|
|
10
|
-
/** Map a raw model string to a canonical table key, or null if unknown.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export declare function
|
|
10
|
+
/** Map a raw model string to a canonical table key, or null if unknown.
|
|
11
|
+
* `provider` scopes the lookup: provider-specific rules win, because they are the
|
|
12
|
+
* only ones that know a hosted bill exists for a model that would otherwise be free. */
|
|
13
|
+
export declare function canonical(model: string, provider?: string): string | null;
|
|
14
|
+
/** The Rate for a model (falls back to a mid-tier rate for unknown models).
|
|
15
|
+
* `provider` scopes the lookup so a self-hosted model stays at $0. */
|
|
16
|
+
export declare function rateFor(model: string, provider?: string): Rate;
|
|
14
17
|
/** True if the model maps to an explicit table entry (not the fallback). */
|
|
15
18
|
export declare function isKnown(model: string): boolean;
|
|
16
19
|
export interface CostArgs {
|
|
@@ -18,6 +21,9 @@ export interface CostArgs {
|
|
|
18
21
|
completionTokens?: number;
|
|
19
22
|
cachedTokens?: number;
|
|
20
23
|
reasoningTokens?: number;
|
|
24
|
+
/** WHO served the call. Decides whether there is a bill at all: the same model id
|
|
25
|
+
* is billed on a hosted provider and free on the developer's own hardware. */
|
|
26
|
+
provider?: string;
|
|
21
27
|
}
|
|
22
28
|
/**
|
|
23
29
|
* USD cost estimate for one model call. `cachedTokens` are a subset of
|
package/dist/pricing.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// authoritative cost comes from the Pyyol Gateway). When a provider changes prices,
|
|
7
7
|
// bump PRICING_VERSION and update the table — never edit silently, so a cost can
|
|
8
8
|
// always be traced to the table that produced it.
|
|
9
|
+
import { isSelfHosted } from "./providers.js";
|
|
9
10
|
// Bump whenever any rate below changes. Stamped onto every estimate.
|
|
10
11
|
export const PRICING_VERSION = "2026-07-24";
|
|
11
12
|
// Canonical model id -> Rate. Lowercase, provider-agnostic.
|
|
@@ -29,12 +30,32 @@ const TABLE = {
|
|
|
29
30
|
// Google (Gemini)
|
|
30
31
|
"gemini-flash": { input: 0.15, output: 0.6, cachedInput: 0.0375 },
|
|
31
32
|
"gemini-pro": { input: 1.25, output: 5.0, cachedInput: 0.3125 },
|
|
33
|
+
// Open-weight served by a HOSTED provider — there IS a per-token bill.
|
|
34
|
+
//
|
|
35
|
+
// "Open weight" does not mean "free". Groq bills per token like anyone else, and
|
|
36
|
+
// recording $0 for it meant a Groq-backed agent reported no cost at all on a platform
|
|
37
|
+
// that advertises verified LLM cost tracking. (The Python SDK already scoped these by
|
|
38
|
+
// provider; this table did not, so the two disagreed about the same call.)
|
|
39
|
+
"groq-llama-8b": { input: 0.05, output: 0.08 },
|
|
40
|
+
"groq-llama-70b": { input: 0.59, output: 0.79 },
|
|
32
41
|
// Open-weight / self-hosted (no per-token bill)
|
|
33
42
|
llama: { input: 0.0, output: 0.0 },
|
|
34
43
|
mistral: { input: 0.0, output: 0.0 },
|
|
35
44
|
qwen: { input: 0.0, output: 0.0 },
|
|
36
45
|
deepseek: { input: 0.27, output: 1.1 },
|
|
37
46
|
};
|
|
47
|
+
// Provider-scoped rules, checked BEFORE the name rules. An open-weight model is $0
|
|
48
|
+
// when you run it yourself and very much not $0 when a hosted provider serves it — and
|
|
49
|
+
// the model id cannot tell you which, since "llama-3.3-70b" is the same string either
|
|
50
|
+
// way. Only an explicitly provider-attributed call gets a hosted rate.
|
|
51
|
+
const PROVIDER_RULES = {
|
|
52
|
+
groq: [
|
|
53
|
+
["llama-3.1-8b", "groq-llama-8b"],
|
|
54
|
+
["llama-3.1-70b", "groq-llama-70b"],
|
|
55
|
+
["llama-3.3-70b", "groq-llama-70b"],
|
|
56
|
+
["llama-4", "groq-llama-70b"],
|
|
57
|
+
],
|
|
58
|
+
};
|
|
38
59
|
// Last-resort rate for an unmapped model (never silently $0 unless open-weight).
|
|
39
60
|
const FALLBACK = { input: 0.5, output: 1.5 };
|
|
40
61
|
// Ordered [substring, canonical] rules; first match wins, most specific first.
|
|
@@ -72,11 +93,17 @@ const RULES = [
|
|
|
72
93
|
["qwen", "qwen"],
|
|
73
94
|
["deepseek", "deepseek"],
|
|
74
95
|
];
|
|
75
|
-
/** Map a raw model string to a canonical table key, or null if unknown.
|
|
76
|
-
|
|
96
|
+
/** Map a raw model string to a canonical table key, or null if unknown.
|
|
97
|
+
* `provider` scopes the lookup: provider-specific rules win, because they are the
|
|
98
|
+
* only ones that know a hosted bill exists for a model that would otherwise be free. */
|
|
99
|
+
export function canonical(model, provider = "") {
|
|
77
100
|
const m = (model ?? "").trim().toLowerCase();
|
|
78
101
|
if (!m)
|
|
79
102
|
return null;
|
|
103
|
+
for (const [needle, key] of PROVIDER_RULES[provider.trim().toLowerCase()] ?? []) {
|
|
104
|
+
if (m.includes(needle))
|
|
105
|
+
return key;
|
|
106
|
+
}
|
|
80
107
|
if (m in TABLE)
|
|
81
108
|
return m;
|
|
82
109
|
for (const [needle, key] of RULES)
|
|
@@ -84,9 +111,19 @@ export function canonical(model) {
|
|
|
84
111
|
return key;
|
|
85
112
|
return null;
|
|
86
113
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
114
|
+
// Rate for a model the developer serves themselves. Not a guess and not a fallback —
|
|
115
|
+
// there is no per-token bill, so any non-zero number here would be fiction.
|
|
116
|
+
const FREE = { input: 0, output: 0, cachedInput: 0 };
|
|
117
|
+
/** The Rate for a model (falls back to a mid-tier rate for unknown models).
|
|
118
|
+
* `provider` scopes the lookup so a self-hosted model stays at $0. */
|
|
119
|
+
export function rateFor(model, provider = "") {
|
|
120
|
+
// Self-hosted first, ahead of every name-based rule. The model id cannot tell you
|
|
121
|
+
// who served it — "llama-3.3-70b" is the same string on Groq's bill and on your own
|
|
122
|
+
// GPU — so without this an Ollama user is charged Groq's rates for electricity they
|
|
123
|
+
// already paid for, and the unknown-model fallback would invent a bill outright.
|
|
124
|
+
if (isSelfHosted(provider.trim().toLowerCase()))
|
|
125
|
+
return FREE;
|
|
126
|
+
const key = canonical(model, provider);
|
|
90
127
|
return key !== null ? TABLE[key] : FALLBACK;
|
|
91
128
|
}
|
|
92
129
|
/** True if the model maps to an explicit table entry (not the fallback). */
|
|
@@ -99,7 +136,7 @@ export function isKnown(model) {
|
|
|
99
136
|
* tokens already counted in `completionTokens` (kept for reporting).
|
|
100
137
|
*/
|
|
101
138
|
export function estimateCost(model, a = {}) {
|
|
102
|
-
const rate = rateFor(model);
|
|
139
|
+
const rate = rateFor(model, a.provider ?? "");
|
|
103
140
|
const prompt = Math.max(0, a.promptTokens ?? 0);
|
|
104
141
|
const completion = Math.max(0, a.completionTokens ?? 0);
|
|
105
142
|
const cached = Math.max(0, Math.min(a.cachedTokens ?? 0, prompt));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const OPENAI = "openai";
|
|
2
|
+
export declare const ANTHROPIC = "anthropic";
|
|
3
|
+
export declare const GOOGLE = "google";
|
|
4
|
+
export declare const GROQ = "groq";
|
|
5
|
+
export declare const OLLAMA = "ollama";
|
|
6
|
+
export declare const SELF_HOSTED = "self-hosted";
|
|
7
|
+
/** True for loopback, link-local and private-network addresses, and for the hostnames
|
|
8
|
+
* that conventionally mean "this machine". A model served from one of these has no
|
|
9
|
+
* per-token bill, which is why it is worth detecting even when unnamed. */
|
|
10
|
+
export declare function isLocalHost(host: string): boolean;
|
|
11
|
+
/** Provider key implied by a base URL, or "" when the host says nothing. A local
|
|
12
|
+
* address always resolves to SOMETHING — never "" — because "we could not tell" and
|
|
13
|
+
* "it runs on your own hardware for free" must not be the same answer. */
|
|
14
|
+
export declare function fromBaseUrl(baseUrl: string): string;
|
|
15
|
+
/** Provider key implied by a client's constructor or module name, or "". */
|
|
16
|
+
export declare function fromName(name: string): string;
|
|
17
|
+
/** The provider serving this call. baseURL wins over the name: the name only says
|
|
18
|
+
* which WIRE FORMAT the client speaks, while the URL says who is on the other end —
|
|
19
|
+
* and for every OpenAI-compatible endpoint those are different answers. */
|
|
20
|
+
export declare function resolve(o: {
|
|
21
|
+
name?: string;
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
fallback?: string;
|
|
24
|
+
}): string;
|
|
25
|
+
/** True when the provider runs on the developer's own hardware, so its tokens carry
|
|
26
|
+
* no per-token bill. */
|
|
27
|
+
export declare function isSelfHosted(provider: string): boolean;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Which provider is actually serving this call?
|
|
2
|
+
//
|
|
3
|
+
// Identifying the provider by the client CLASS is not enough, and getting it wrong is
|
|
4
|
+
// not cosmetic: the provider decides whether a call is priced or free, and it is half
|
|
5
|
+
// of the model attribution the public benchmark ranks on.
|
|
6
|
+
//
|
|
7
|
+
// The problem is that most of the ecosystem speaks the OpenAI wire format. Ollama,
|
|
8
|
+
// vLLM, LM Studio, llama.cpp, OpenRouter, Together, Groq, DeepSeek, Azure and others
|
|
9
|
+
// are all routinely driven through the OpenAI SDK with nothing changed but `baseURL`.
|
|
10
|
+
// Classifying those by constructor name calls every one of them "openai" — pricing a
|
|
11
|
+
// locally-served Llama at OpenAI's rates and filing it under the wrong vendor.
|
|
12
|
+
//
|
|
13
|
+
// Resolution order: baseURL host → module/constructor name → caller's fallback.
|
|
14
|
+
// Anything on a loopback or private address is SELF-HOSTED even when the runtime is
|
|
15
|
+
// unrecognised: it has no per-token bill, and "unknown" would price it as if it did.
|
|
16
|
+
//
|
|
17
|
+
// Keep the provider keys here in step with `internal/rating/taxonomy.go` and the
|
|
18
|
+
// Python SDK's `providers.py` — the backend classifies exactly these strings.
|
|
19
|
+
export const OPENAI = "openai";
|
|
20
|
+
export const ANTHROPIC = "anthropic";
|
|
21
|
+
export const GOOGLE = "google";
|
|
22
|
+
export const GROQ = "groq";
|
|
23
|
+
export const OLLAMA = "ollama";
|
|
24
|
+
export const SELF_HOSTED = "self-hosted";
|
|
25
|
+
/** host substring -> provider key. Most specific first. */
|
|
26
|
+
const HOST_RULES = [
|
|
27
|
+
["api.openai.com", OPENAI],
|
|
28
|
+
["openai.azure.com", "azure"],
|
|
29
|
+
["api.anthropic.com", ANTHROPIC],
|
|
30
|
+
["bedrock-runtime", "bedrock"],
|
|
31
|
+
["bedrock", "bedrock"],
|
|
32
|
+
["generativelanguage.googleapis.com", GOOGLE],
|
|
33
|
+
["aiplatform.googleapis.com", "vertex"],
|
|
34
|
+
["api.groq.com", GROQ],
|
|
35
|
+
["openrouter.ai", "openrouter"],
|
|
36
|
+
["api.together.xyz", "together"],
|
|
37
|
+
["together.ai", "together"],
|
|
38
|
+
["api.fireworks.ai", "fireworks"],
|
|
39
|
+
["api.deepinfra.com", "deepinfra"],
|
|
40
|
+
["api.mistral.ai", "mistral"],
|
|
41
|
+
["api.deepseek.com", "deepseek"],
|
|
42
|
+
["api.cohere.ai", "cohere"],
|
|
43
|
+
["api.cohere.com", "cohere"],
|
|
44
|
+
["api.x.ai", "xai"],
|
|
45
|
+
["api.perplexity.ai", "perplexity"],
|
|
46
|
+
["api.cerebras.ai", "cerebras"],
|
|
47
|
+
["api.sambanova.ai", "sambanova"],
|
|
48
|
+
["api.studio.nebius", "nebius"],
|
|
49
|
+
["api.hyperbolic.xyz", "hyperbolic"],
|
|
50
|
+
["api.moonshot", "moonshot"],
|
|
51
|
+
["dashscope.aliyuncs.com", "alibaba"],
|
|
52
|
+
];
|
|
53
|
+
/** Default ports of the common local runtimes. */
|
|
54
|
+
const LOCAL_PORTS = {
|
|
55
|
+
"11434": OLLAMA,
|
|
56
|
+
"1234": "lmstudio",
|
|
57
|
+
"8000": "vllm",
|
|
58
|
+
"8080": "llamacpp",
|
|
59
|
+
"5000": "localai",
|
|
60
|
+
"3000": SELF_HOSTED,
|
|
61
|
+
"9997": SELF_HOSTED,
|
|
62
|
+
};
|
|
63
|
+
/** constructor/module-name substring -> provider. "openai" LAST: several packages
|
|
64
|
+
* embed it in their own names. */
|
|
65
|
+
const NAME_RULES = [
|
|
66
|
+
["ollama", OLLAMA],
|
|
67
|
+
["anthropic", ANTHROPIC],
|
|
68
|
+
["groq", GROQ],
|
|
69
|
+
["mistral", "mistral"],
|
|
70
|
+
["cohere", "cohere"],
|
|
71
|
+
["googlegenai", GOOGLE],
|
|
72
|
+
["generativeai", GOOGLE],
|
|
73
|
+
["google", GOOGLE],
|
|
74
|
+
["openai", OPENAI],
|
|
75
|
+
];
|
|
76
|
+
const SELF_HOSTED_KEYS = new Set([OLLAMA, SELF_HOSTED, "vllm", "lmstudio", "llamacpp", "localai", "tgi"]);
|
|
77
|
+
function parse(baseUrl) {
|
|
78
|
+
if (!baseUrl)
|
|
79
|
+
return { host: "", port: "" };
|
|
80
|
+
const withScheme = baseUrl.includes("://") ? baseUrl : `http://${baseUrl}`;
|
|
81
|
+
try {
|
|
82
|
+
const u = new URL(withScheme);
|
|
83
|
+
return { host: u.hostname.toLowerCase(), port: u.port };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { host: "", port: "" };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** True for loopback, link-local and private-network addresses, and for the hostnames
|
|
90
|
+
* that conventionally mean "this machine". A model served from one of these has no
|
|
91
|
+
* per-token bill, which is why it is worth detecting even when unnamed. */
|
|
92
|
+
export function isLocalHost(host) {
|
|
93
|
+
if (!host)
|
|
94
|
+
return false;
|
|
95
|
+
if (["localhost", "127.0.0.1", "::1", "0.0.0.0", "host.docker.internal"].includes(host))
|
|
96
|
+
return true;
|
|
97
|
+
if (host.endsWith(".local") || host.endsWith(".internal"))
|
|
98
|
+
return true;
|
|
99
|
+
// IPv4 private ranges: 10/8, 192.168/16, 172.16–31/12, 127/8, 169.254/16.
|
|
100
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
101
|
+
if (!m)
|
|
102
|
+
return false;
|
|
103
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
104
|
+
return a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254);
|
|
105
|
+
}
|
|
106
|
+
/** Provider key implied by a base URL, or "" when the host says nothing. A local
|
|
107
|
+
* address always resolves to SOMETHING — never "" — because "we could not tell" and
|
|
108
|
+
* "it runs on your own hardware for free" must not be the same answer. */
|
|
109
|
+
export function fromBaseUrl(baseUrl) {
|
|
110
|
+
const { host, port } = parse(baseUrl);
|
|
111
|
+
if (!host)
|
|
112
|
+
return "";
|
|
113
|
+
for (const [needle, provider] of HOST_RULES) {
|
|
114
|
+
if (host.includes(needle))
|
|
115
|
+
return provider;
|
|
116
|
+
}
|
|
117
|
+
if (isLocalHost(host))
|
|
118
|
+
return LOCAL_PORTS[port] ?? SELF_HOSTED;
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
/** Provider key implied by a client's constructor or module name, or "". */
|
|
122
|
+
export function fromName(name) {
|
|
123
|
+
const n = (name || "").toLowerCase().replace(/[^a-z]/g, "");
|
|
124
|
+
for (const [needle, provider] of NAME_RULES) {
|
|
125
|
+
if (n.includes(needle))
|
|
126
|
+
return provider;
|
|
127
|
+
}
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
/** The provider serving this call. baseURL wins over the name: the name only says
|
|
131
|
+
* which WIRE FORMAT the client speaks, while the URL says who is on the other end —
|
|
132
|
+
* and for every OpenAI-compatible endpoint those are different answers. */
|
|
133
|
+
export function resolve(o) {
|
|
134
|
+
return fromBaseUrl(o.baseUrl ?? "") || fromName(o.name ?? "") || o.fallback || "";
|
|
135
|
+
}
|
|
136
|
+
/** True when the provider runs on the developer's own hardware, so its tokens carry
|
|
137
|
+
* no per-token bill. */
|
|
138
|
+
export function isSelfHosted(provider) {
|
|
139
|
+
return SELF_HOSTED_KEYS.has(provider);
|
|
140
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "1.
|
|
1
|
+
export declare const SDK_VERSION = "1.9.0";
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
package/rules/llms-full.txt
CHANGED
|
@@ -1172,7 +1172,55 @@ JSON (YAML also accepted). All keys are **camelCase**.
|
|
|
1172
1172
|
| `runtime.maxMemory` | string, e.g. `"256Mi"` |
|
|
1173
1173
|
| `sdk.language` | required (`python` / `js`) |
|
|
1174
1174
|
| `contact.email` | valid email |
|
|
1175
|
-
| `model` | **optional
|
|
1175
|
+
| `model` | **optional**, but scaffolded by `pyyol init` — see below. If present, `provider` + `model` are both required. |
|
|
1176
|
+
|
|
1177
|
+
## How your model is identified on the benchmark
|
|
1178
|
+
|
|
1179
|
+
The [model board](https://pyyol.com/models) ranks by the model that actually played
|
|
1180
|
+
each match, resolved at whichever of these tiers it can reach — best first:
|
|
1181
|
+
|
|
1182
|
+
| tier | source | can you misreport it? |
|
|
1183
|
+
| --- | --- | --- |
|
|
1184
|
+
| **verified** | the model name in the provider's own API response, read by the Pyyol gateway | no |
|
|
1185
|
+
| **observed** | the model your SDK reported for the calls it made that turn | yes, but per call |
|
|
1186
|
+
| **claimed** | this `model:` block | yes |
|
|
1187
|
+
|
|
1188
|
+
Two practical consequences:
|
|
1189
|
+
|
|
1190
|
+
- Route your LLM calls through the gateway (`/gw/openai/...`, `/gw/anthropic/...`) and
|
|
1191
|
+
your rows show as **verified** — and your cost-per-win is computed from spend the
|
|
1192
|
+
server measured rather than from a number your agent reported.
|
|
1193
|
+
- Fill the `model:` block in anyway. It is the fallback for any match where neither
|
|
1194
|
+
the gateway nor the SDK saw a model name, and an agent with none can disappear from
|
|
1195
|
+
the board entirely. `pyyol init` writes placeholders (`your-provider` /
|
|
1196
|
+
`your-model`) — replace them, because an unedited block is not a useful claim.
|
|
1197
|
+
|
|
1198
|
+
### Running something other than OpenAI or Anthropic
|
|
1199
|
+
|
|
1200
|
+
`pyyol.instrument()` identifies your model whatever serves it. It resolves the
|
|
1201
|
+
provider from the client's **base URL** first, then its SDK, because almost everything
|
|
1202
|
+
speaks the OpenAI wire format — so an OpenAI client pointed somewhere else is not an
|
|
1203
|
+
OpenAI call, and treating it as one would price it wrong.
|
|
1204
|
+
|
|
1205
|
+
| you run | what happens |
|
|
1206
|
+
| --- | --- |
|
|
1207
|
+
| OpenAI / Anthropic SDKs | captured natively |
|
|
1208
|
+
| **Ollama** (native client or `ollama.chat`) | captured, reported as `ollama`, priced at **$0** |
|
|
1209
|
+
| OpenAI SDK → `localhost:11434` / `:1234` / `:8000` / `:8080` | detected as ollama / LM Studio / vLLM / llama.cpp, priced at **$0** |
|
|
1210
|
+
| OpenAI SDK → any private or loopback address | `self-hosted`, priced at **$0** |
|
|
1211
|
+
| OpenAI SDK → Groq, OpenRouter, Together, DeepSeek, Fireworks, xAI, Perplexity, Cerebras, Azure… | detected by host and priced as that provider |
|
|
1212
|
+
| Google Gemini (`google-genai` or `google-generativeai`) | captured natively |
|
|
1213
|
+
| Cohere | captured natively |
|
|
1214
|
+
|
|
1215
|
+
Self-hosted models are recorded at **$0 per token** — you already paid for the
|
|
1216
|
+
hardware — but their tokens, latency and move quality are measured exactly like
|
|
1217
|
+
anyone else's, so they compete on the board on equal terms. The board also groups by
|
|
1218
|
+
open-weights vs proprietary and hosted vs self-hosted, so you can see how your setup
|
|
1219
|
+
compares to the camp rather than only to individual models.
|
|
1220
|
+
|
|
1221
|
+
If you use a client the SDK cannot recognise, call `pyyol.record_response(resp,
|
|
1222
|
+
provider="...")` yourself, or `pyyol.route(client, provider="...")` to name it
|
|
1223
|
+
explicitly.
|
|
1176
1224
|
|
|
1177
1225
|
## The endpoint secret
|
|
1178
1226
|
|