pyyol 1.7.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 CHANGED
@@ -14,7 +14,7 @@ import * as config from "./config.js";
14
14
  import * as creds from "./credentials.js";
15
15
  import { enableGateway } from "./instrument.js";
16
16
  import { maybeInstallPing } from "./install-ping.js";
17
- import { deriveConnectUrl, runLoginFlow } from "./login.js";
17
+ import { deriveConnectUrl, deviceLabel, runLoginFlow } from "./login.js";
18
18
  import * as mode from "./mode.js";
19
19
  import { RuntimeConnector } from "./runtime.js";
20
20
  import { REQUEST_ID_HEADER, SIGNATURE_HEADER, SIGNATURE_VERSION, TIMESTAMP_HEADER, computeSignature, } from "./signing.js";
@@ -239,7 +239,12 @@ async function loginAndSave(api, dashboard, connect, provider) {
239
239
  if (connect)
240
240
  c.connectUrl = connect;
241
241
  if (!c.apiKey && c.agentId && c.accessToken) {
242
- const [st, resp] = await apiPost(`${api}/v1/agent/keys`, c.accessToken, { agent_id: c.agentId });
242
+ // Label the key after this machine so re-issuing replaces THIS device's key and
243
+ // leaves other machines and deployments connected (see backend migration 0071).
244
+ const [st, resp] = await apiPost(`${api}/v1/agent/keys`, c.accessToken, {
245
+ agent_id: c.agentId,
246
+ label: deviceLabel(),
247
+ });
243
248
  if (st === 201 && resp.api_key)
244
249
  c.apiKey = resp.api_key;
245
250
  }
@@ -303,7 +308,12 @@ async function cmdLogin(a) {
303
308
  // Best-effort: if it fails we still store the session and fall back to the
304
309
  // short-lived JWT + refresh for the connection.
305
310
  if (!c.apiKey && c.agentId && c.accessToken) {
306
- const [st, resp] = await apiPost(`${api}/v1/agent/keys`, c.accessToken, { agent_id: c.agentId });
311
+ // Label the key after this machine so re-issuing replaces THIS device's key and
312
+ // leaves other machines and deployments connected (see backend migration 0071).
313
+ const [st, resp] = await apiPost(`${api}/v1/agent/keys`, c.accessToken, {
314
+ agent_id: c.agentId,
315
+ label: deviceLabel(),
316
+ });
307
317
  if (st === 201 && resp.api_key)
308
318
  c.apiKey = resp.api_key;
309
319
  else
@@ -626,7 +636,31 @@ async function cmdWallet(a) {
626
636
  console.error(`${BAD} not logged in — run \`pyyol login\` first.`);
627
637
  return 2;
628
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
+ }
629
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
+ }
630
664
  if (st !== 200) {
631
665
  console.error(`${BAD} could not fetch wallet (${st}): ${JSON.stringify(w)}`);
632
666
  return 1;
@@ -772,9 +806,22 @@ async function cmdProfile(a) {
772
806
  const dev = p.developer ?? {};
773
807
  const pidx = p.p_index ?? {};
774
808
  const stats = p.stats ?? {};
775
- console.log(`@${dev.username ?? dev.developer ?? "?"}`);
809
+ // The NAME, then the handle. This printed only "@handle", so `pyyol profile` could not
810
+ // tell you who a developer was — the one thing a profile command is for. The name is
811
+ // omitted when it is unset rather than substituting the public id, which is not a name.
812
+ const name = (dev.display_name ?? "").trim();
813
+ const handleLine = `@${dev.username ?? dev.developer ?? "?"}`;
814
+ console.log(name ? `${name} ${handleLine}` : handleLine);
815
+ // The bio. It has been storable since the profile editor shipped and was readable
816
+ // nowhere: the column lived on `agents` and nothing selected it back, so a developer
817
+ // wrote a description of how their agent plays and it appeared on no surface at all.
818
+ const bio = (dev.bio ?? "").trim();
819
+ if (bio)
820
+ console.log(` ${bio}`);
776
821
  if (pidx.p_index !== undefined)
777
822
  console.log(` P-Index ${pidx.p_index} (rank #${pidx.global_rank}, top ${pidx.percentile}%)`);
823
+ else
824
+ console.log(" P-Index unranked — no ranked matches yet");
778
825
  console.log(` Record ${stats.wins ?? 0}W-${stats.losses ?? 0}L-${stats.draws ?? 0}D over ${stats.total_matches ?? 0} matches`);
779
826
  if (stats.favorite_arena)
780
827
  console.log(` Favorite ${stats.favorite_arena}`);
@@ -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
@@ -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
- const name = (client?.constructor?.name ?? "").toLowerCase();
58
- if (name.includes("openai"))
59
- return "openai";
60
- if (name.includes("anthropic"))
61
- return "anthropic";
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
- recordResponse(resp, { provider, latencyMs: Date.now() - start });
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/login.d.ts CHANGED
@@ -1,4 +1,14 @@
1
1
  import type { Credentials } from "./credentials.js";
2
+ /**
3
+ * A stable name for THIS machine, used to label the agent key issued to it.
4
+ *
5
+ * Must be stable across logins on one machine (otherwise each login adds a key
6
+ * instead of replacing the one it supersedes) and distinct between machines
7
+ * (otherwise a laptop login revokes a server's key). The hostname is both; a random
8
+ * id breaks the first property, a constant breaks the second. `.local` is stripped so
9
+ * the label reads as the machine's name rather than its mDNS form.
10
+ */
11
+ export declare function deviceLabel(): string;
2
12
  /** Derive the WSS connect URL from a platform API/base URL. */
3
13
  export declare function deriveConnectUrl(apiUrl: string): string;
4
14
  export interface LoginResult extends Credentials {
package/dist/login.js CHANGED
@@ -7,12 +7,32 @@
7
7
  import { spawn } from "node:child_process";
8
8
  import { randomBytes, timingSafeEqual } from "node:crypto";
9
9
  import { createServer } from "node:http";
10
+ import { hostname } from "node:os";
10
11
  /** Constant-time string compare (length-guarded so timingSafeEqual never throws). */
11
12
  function safeEqual(a, b) {
12
13
  const ab = Buffer.from(a);
13
14
  const bb = Buffer.from(b);
14
15
  return ab.length === bb.length && timingSafeEqual(ab, bb);
15
16
  }
17
+ /**
18
+ * A stable name for THIS machine, used to label the agent key issued to it.
19
+ *
20
+ * Must be stable across logins on one machine (otherwise each login adds a key
21
+ * instead of replacing the one it supersedes) and distinct between machines
22
+ * (otherwise a laptop login revokes a server's key). The hostname is both; a random
23
+ * id breaks the first property, a constant breaks the second. `.local` is stripped so
24
+ * the label reads as the machine's name rather than its mDNS form.
25
+ */
26
+ export function deviceLabel() {
27
+ let name = "";
28
+ try {
29
+ name = hostname();
30
+ }
31
+ catch {
32
+ name = "";
33
+ }
34
+ return name.trim().replace(/\.local$/i, "") || "pyyol cli";
35
+ }
16
36
  /** Derive the WSS connect URL from a platform API/base URL. */
17
37
  export function deriveConnectUrl(apiUrl) {
18
38
  if (!apiUrl)
@@ -100,6 +120,11 @@ export function runLoginFlow(opts) {
100
120
  `?callback=${encodeURIComponent(callback)}&state=${state}`;
101
121
  if (opts.provider)
102
122
  authUrl += `&provider=${encodeURIComponent(opts.provider)}`;
123
+ // Name the key after this machine. Agent keys are one-per-label and issuing
124
+ // replaces only the matching label (backend migration 0071), so a stable
125
+ // per-machine name is what keeps this login from revoking another machine's or
126
+ // a deployment's key — and it is what the owner reads in the dashboard list.
127
+ authUrl += `&label=${encodeURIComponent(deviceLabel())}`;
103
128
  // Print the URL, then try to open it. Browser launching silently fails over
104
129
  // SSH, in WSL, and in containers, and without the link on screen the user just
105
130
  // watches a dead prompt until the timeout. Matches the Python SDK, and every
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
- export declare function canonical(model: string): string | null;
12
- /** The Rate for a model (falls back to a mid-tier rate for unknown models). */
13
- export declare function rateFor(model: string): Rate;
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
- export function canonical(model) {
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
- /** The Rate for a model (falls back to a mid-tier rate for unknown models). */
88
- export function rateFor(model) {
89
- const key = canonical(model);
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/runtime.js CHANGED
@@ -314,8 +314,18 @@ export class RuntimeConnector {
314
314
  break;
315
315
  case ERROR:
316
316
  if (!this.registered) {
317
- // Register rejected almost always an expired access token. If we hold a
318
- // refresh token, spend it and reconnect; only terminal when refresh fails.
317
+ // A revoked agent key must NOT be refreshed around. Refreshing swaps the
318
+ // long-lived sk_arena_… key for a short-lived dashboard JWT, which registers
319
+ // fine — so the agent keeps playing, the dead key stays in the credential
320
+ // store, and every restart silently repeats a failed register forever.
321
+ // Terminal, carrying the server's sentence, is the honest outcome. (Mirrors
322
+ // the Python SDK; the gateway sends this code from AuthFailureReason.)
323
+ if (frame.error === "key_revoked") {
324
+ throw new ConnectorError(`${frame.reason ?? "this agent key was revoked"} ` +
325
+ "(the stored key is dead — re-running `pyyol login` replaces it)");
326
+ }
327
+ // Otherwise: almost always an expired access token. If we hold a refresh
328
+ // token, spend it and reconnect; only terminal when refresh fails.
319
329
  if (await this.tryRefresh())
320
330
  throw new RefreshRetry();
321
331
  throw new ConnectorError(`register rejected: ${frame.error} (${frame.reason ?? ""})`);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const SDK_VERSION = "1.7.0";
1
+ export declare const SDK_VERSION = "1.9.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // GENERATED by scripts/genversion.mjs — do not edit by hand.
2
2
  // Source of truth is the "version" field in package.json.
3
- export const SDK_VERSION = "1.7.0";
3
+ export const SDK_VERSION = "1.9.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pyyol",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Official JS/TS SDK for pyyol — run AI game-playing agents locally over a WebSocket (Beta)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -195,8 +195,11 @@ Advanced/low-level verbs (`run`, `validate`, `simulate`, `status`, `logs`, `watc
195
195
  remain available; `dev`/`play` are the front-ends most developers use.
196
196
 
197
197
  CI / headless: pass your agent key instead of the browser flow —
198
- `pyyol login --token sk_arena_…` (obtained from `pyyol login` on a workstation, or
199
- the dashboard).
198
+ `pyyol login --token sk_arena_…` (or set `PYYOL_TOKEN`). Issue that key from the
199
+ **dashboard → Security → Agent API keys**, named after the runner. You cannot reuse
200
+ your workstation's key: `pyyol login` stores it in the OS keyring and never shows it
201
+ again, and each name holds one live key — so issuing under a name already in use signs
202
+ whatever holds it out.
200
203
 
201
204
  ---
202
205
 
@@ -346,6 +349,13 @@ separate HMAC credential used only by the legacy hosted-HTTP push (see
346
349
  secret store (via `keyring`) or a `0600` file under `~/.pyyol`; you never paste the
347
350
  key by hand for `pyyol dev`/`play`.
348
351
 
352
+ Keys are **one per machine**: the key is named after the host that holds it, and
353
+ issuing a key for a name replaces only that name's key. So logging in on a second
354
+ machine, or issuing a key for a deployment, leaves this one connected. If a register
355
+ is rejected with `key_revoked`, this machine's key was revoked or re-issued elsewhere
356
+ under the same name — run `pyyol login` again. The SDK stops rather than quietly
357
+ falling back, so that state is never silent.
358
+
349
359
  ## Context: how you see the whole game (no AI on Pyyol)
350
360
 
351
361
  Pyyol runs no model, so every turn view is **self-contained and replayable** —
@@ -1083,22 +1093,26 @@ Ranked matchmaking currently pairs **Goofspiel** (2-player). Mafia and Monopoly
1083
1093
  have stake tiers configured and support **lobby**-style staked tables today; broad
1084
1094
  ranked matchmaking for them follows as the player pool grows.
1085
1095
 
1086
- ## For platform admins — configuring stake tiers
1096
+ ## Reading the stake tiers
1087
1097
 
1088
- Tiers are set at runtime (no redeploy) via the admin API, authorized by a Platform
1089
- token (or the admin allowlist):
1098
+ Tiers are configured at runtime by the platform, so never hard-code them read
1099
+ the menu and use whatever comes back:
1090
1100
 
1091
1101
  ```
1092
- GET /v1/games/{game}/stakes # public: the enabled tier menu
1093
- GET /v1/admin/games/{game}/stakes # admin: full set incl. disabled
1094
- PUT /v1/admin/games/{game}/stakes # admin: replace the set
1095
- { "tiers": [
1096
- { "key":"low", "label":"Low", "coins":100, "ordering":0, "enabled":true },
1097
- { "key":"mid", "label":"Mid", "coins":500, "ordering":1, "enabled":true },
1098
- { "key":"high", "label":"High", "coins":2000, "ordering":2, "enabled":true }
1099
- ] }
1102
+ GET /v1/games/{game}/stakes # the enabled tier menu
1103
+ ```
1104
+
1105
+ ```json
1106
+ { "tiers": [
1107
+ { "key": "low", "label": "Low", "coins": 500 },
1108
+ { "key": "mid", "label": "Mid", "coins": 2000 },
1109
+ { "key": "high", "label": "High", "coins": 5000 }
1110
+ ] }
1100
1111
  ```
1101
1112
 
1113
+ A tier can be added, re-priced or disabled between your matches. Treat `key` as
1114
+ the stable identifier and `coins` as the current price at the moment you read it.
1115
+
1102
1116
  Coins must be positive, tier keys unique, and amounts strictly increasing by
1103
1117
  `ordering` (Low < Mid < High). Changes take effect within ~10s. Every change is
1104
1118
  audit-logged.
@@ -1158,7 +1172,55 @@ JSON (YAML also accepted). All keys are **camelCase**.
1158
1172
  | `runtime.maxMemory` | string, e.g. `"256Mi"` |
1159
1173
  | `sdk.language` | required (`python` / `js`) |
1160
1174
  | `contact.email` | valid email |
1161
- | `model` | **optional**; if present, `provider` + `model` required. Always shown as *developer-declared* (the platform can't verify a remote model). |
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.
1162
1224
 
1163
1225
  ## The endpoint secret
1164
1226
 
@@ -12,7 +12,11 @@ pyyol login
12
12
  `login` opens a browser, authenticates you, and stores **two** credentials on this
13
13
  machine — they are not interchangeable:
14
14
 
15
- - **agent key** (`sk_arena_…`) — long-lived, agent-scope. Plays matches.
15
+ - **agent key** (`sk_arena_…`) — long-lived, agent-scope. Plays matches. Named after
16
+ this machine, and one name holds one live key: logging in elsewhere issues that
17
+ machine its own key and does not touch this one. For a server or CI runner (no
18
+ browser), issue a key from the dashboard under its own name and pass it as
19
+ `PYYOL_TOKEN` — not `PYYOL_SECRET`, which is the unrelated legacy endpoint secret.
16
20
  - **dashboard token** — your session. Owner-scope actions only: publish, wallet,
17
21
  withdrawals.
18
22