@bivelio/savings-layer 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -1
- package/dist/gateway/bin.js +146 -79
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/docs/terminales/README.md +133 -0
- package/docs/terminales/claude-code.md +110 -0
- package/docs/terminales/cline.md +53 -0
- package/docs/terminales/codex.md +132 -0
- package/docs/terminales/crush.md +52 -0
- package/docs/terminales/droid.md +55 -0
- package/docs/terminales/goose.md +54 -0
- package/docs/terminales/kilo.md +50 -0
- package/docs/terminales/opencode.md +67 -0
- package/docs/terminales/qwen-code.md +76 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -33,6 +33,14 @@ charge a flat subscription, so there is no per‑token bill to cut. BV‑SALA is
|
|
|
33
33
|
applications that call the API with their own key. The whole story, in plain words:
|
|
34
34
|
**[Where does the saving install?](https://savings.bivelio.com/en/how-it-works)**
|
|
35
35
|
|
|
36
|
+
**Claude Code, Codex CLI, Goose or Qwen Code with an API key?** The package also
|
|
37
|
+
ships a local measuring gateway: `bvsala claude` (or `bvsala codex`, `bvsala
|
|
38
|
+
goose`, `bvsala qwen`) launches the terminal through it and your real spend
|
|
39
|
+
shows up in your dashboard, token by token — nothing to configure, nothing
|
|
40
|
+
persisted. For opencode, Crush, Droid, Cline or Kilo Code, `bvsala doctor`
|
|
41
|
+
prints the exact config block for each one you have installed. Today it
|
|
42
|
+
measures; it does not optimize yet.
|
|
43
|
+
|
|
36
44
|
## Try it before you install
|
|
37
45
|
|
|
38
46
|
Run the real optimizer **free in your browser** — pick an example or paste your own
|
|
@@ -60,7 +68,7 @@ export BIVELIO_SERVICE_KEY=… # service-account key: auto-renewal + seat mete
|
|
|
60
68
|
```ts
|
|
61
69
|
import { createSavingsLayer, OpenAICompatibleProvider } from "@bivelio/savings-layer";
|
|
62
70
|
|
|
63
|
-
// Your provider. LLM traffic goes straight to it —
|
|
71
|
+
// Your provider. LLM traffic goes straight to it — the SDK never proxies it.
|
|
64
72
|
// Any OpenAI-compatible endpoint works: OpenAI, Azure, vLLM, SGLang, a gateway.
|
|
65
73
|
const provider = new OpenAICompatibleProvider({
|
|
66
74
|
baseUrl: "https://api.openai.com/v1",
|
package/dist/gateway/bin.js
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {spawn}from'child_process';import {createServer}from'http';import {randomUUID,createHash}from'crypto';var
|
|
3
|
-
|
|
2
|
+
import {spawn}from'child_process';import {existsSync,readFileSync}from'fs';import {homedir}from'os';import {join,delimiter}from'path';import {createServer}from'http';import {randomUUID,createHash}from'crypto';import*as T from'zlib';var be=new Set;function w(e,t){be.has(e)||(be.add(e),console.warn(t));}var I=class{options;now;flushIntervalMs;maxBatch;maxBuffer;buffer=[];seatId;ci;timer;inflight=null;fallosSeguidos=0;constructor(t){this.options=t,this.now=t.now??(()=>Date.now()),this.flushIntervalMs=
|
|
3
|
+
t.flushIntervalMs??15e3,this.maxBatch=t.maxBatch??50,this.maxBuffer=t.maxBuffer??1e3,this.ci=t.ci??false;}setSeat(t,n){t&&(this.seatId=t),this.ci=n;}record(t){this.buffer.push({report:t,at:this.now()}),this.
|
|
4
4
|
buffer.length>this.maxBuffer&&this.buffer.splice(0,this.buffer.length-this.maxBuffer),this.buffer.length>=this.maxBatch&&this.flush();}start(){this.timer||(this.timer=setInterval(()=>{this.flush();},this.
|
|
5
|
-
flushIntervalMs),this.timer.unref?.());}stop(){this.timer&&(clearInterval(this.timer),this.timer=void 0),this.flush();}async stopAndDrain(
|
|
6
|
-
Math.ceil(this.maxBuffer/this.maxBatch)+4,
|
|
7
|
-
inflight)return this.inflight;if(this.buffer.length!==0){this.inflight=this.hacerFlush();try{await this.inflight;}finally{this.inflight=null;}}}async hacerFlush(){let
|
|
8
|
-
fetch;if(!
|
|
9
|
-
er ${this.options.serviceAccountKey}`},body:JSON.stringify(
|
|
10
|
-
|
|
11
|
-
revocada? \xBFde otra organizaci\xF3n?). Mientras no funcione NO se reporta uso: el panel dir\xE1 que no ve tr\xE1fico y los asientos facturables saldr\xE1n a cero.`),this.options.onAuthError?.(
|
|
12
|
-
(this.fallosSeguidos++,this.avisarDeFallo(
|
|
13
|
-
fallosSeguidos++,this.requeue(n),this.avisarDeFallo(
|
|
14
|
-
warn(`[bv-sala] el env\xEDo de uso lleva ${n} intentos seguidos fallando contra ${this.options.url} (${
|
|
15
|
-
fico y los asientos facturables saldr\xE1n a cero. Comprueba 'meteringUrl' \u2014 por defecto es \`\${licenseServerUrl}/api/metering/usage\`.`);}requeue(
|
|
16
|
-
length>this.maxBuffer&&(this.buffer.length=this.maxBuffer);}toWire(
|
|
17
|
-
model:n.model,at:
|
|
5
|
+
flushIntervalMs),this.timer.unref?.());}stop(){this.timer&&(clearInterval(this.timer),this.timer=void 0),this.flush();}async stopAndDrain(t){this.timer&&(clearInterval(this.timer),this.timer=void 0);let n=t??
|
|
6
|
+
Math.ceil(this.maxBuffer/this.maxBatch)+4,o=0;for(let r=0;r<n&&this.buffer.length>0;r++){let s=this.buffer.length;if(await this.flush(),o=this.buffer.length>=s?o+1:0,o>=2)break}}async flush(){if(this.
|
|
7
|
+
inflight)return this.inflight;if(this.buffer.length!==0){this.inflight=this.hacerFlush();try{await this.inflight;}finally{this.inflight=null;}}}async hacerFlush(){let t=this.options.fetchImpl??globalThis.
|
|
8
|
+
fetch;if(!t)return;let n=this.buffer.splice(0,this.maxBatch);try{let o=n.map(s=>this.toWire(s)),r=await t(this.options.url,{method:"POST",headers:{"content-type":"application/json",authorization:`Bear\
|
|
9
|
+
er ${this.options.serviceAccountKey}`},body:JSON.stringify(o)});r.ok?this.fallosSeguidos=0:r.status>=500?(this.fallosSeguidos++,this.requeue(n),this.avisarDeFallo(`el servidor respondi\xF3 ${r.status}`)):
|
|
10
|
+
r.status===401||r.status===403?(w(`metering-auth-${r.status}`,`[bv-sala] el env\xEDo de uso est\xE1 siendo rechazado con HTTP ${r.status} en ${this.options.url}. Revisa 'serviceAccountKey' (\xBFrotada? \xBF\
|
|
11
|
+
revocada? \xBFde otra organizaci\xF3n?). Mientras no funcione NO se reporta uso: el panel dir\xE1 que no ve tr\xE1fico y los asientos facturables saldr\xE1n a cero.`),this.options.onAuthError?.(r.status)):
|
|
12
|
+
(this.fallosSeguidos++,this.avisarDeFallo(r.status===404||r.status===405?`HTTP ${r.status}: la URL de env\xEDo parece equivocada`:`el servidor rechaz\xF3 el lote con HTTP ${r.status}`));}catch(o){this.
|
|
13
|
+
fallosSeguidos++,this.requeue(n),this.avisarDeFallo(o instanceof Error?o.message:"error de red");}}avisarDeFallo(t){if(this.options.silenceWarnings)return;let n=this.fallosSeguidos;n<3||(n-3)%12!==0||console.
|
|
14
|
+
warn(`[bv-sala] el env\xEDo de uso lleva ${n} intentos seguidos fallando contra ${this.options.url} (${t}). Las peticiones SIGUEN funcionando, pero el uso no est\xE1 llegando: el panel dir\xE1 que no ve tr\xE1\
|
|
15
|
+
fico y los asientos facturables saldr\xE1n a cero. Comprueba 'meteringUrl' \u2014 por defecto es \`\${licenseServerUrl}/api/metering/usage\`.`);}requeue(t){this.buffer=[...t,...this.buffer],this.buffer.
|
|
16
|
+
length>this.maxBuffer&&(this.buffer.length=this.maxBuffer);}toWire(t){let n=t.report;return {orgId:this.options.orgId??"self",...this.seatId?{seatId:this.seatId}:{},...this.ci?{ci:true}:{},requestId:n.requestId,
|
|
17
|
+
model:n.model,at:t.at,avoidedLlm:n.calls.avoidedLlm,actualLlm:n.calls.actualLlm,baselineInputTokens:n.tokens.baselineInput,actualInputTokens:n.tokens.actualInput,eliminatedInputTokens:n.tokens.eliminatedInput,
|
|
18
18
|
compressedInputTokens:n.tokens.compressedInput,baselineOutputTokens:n.tokens.baselineOutput,actualOutputTokens:n.tokens.actualOutput,providerCachedInputTokens:n.tokens.providerCachedInput??0,cacheResult:n.
|
|
19
|
-
cache.result,baselineCost:n.cost.baselineEstimate,actualCost:n.cost.actual,netSavings:n.cost.netSavings,...n.cost.basis?{basis:n.cost.basis}:{}}}};function
|
|
20
|
-
return new RegExp(`-${
|
|
21
|
-
|
|
22
|
-
if(
|
|
23
|
-
|
|
24
|
-
ONS","GITLAB_CI","CIRCLECI","TRAVIS","JENKINS_URL","BUILDKITE","TF_BUILD","TEAMCITY_VERSION","BITBUCKET_BUILD_NUMBER","CODEBUILD_BUILD_ID","HEROKU_TEST_RUN_ID","DRONE"];function
|
|
25
|
-
return false;for(let
|
|
26
|
-
|
|
27
|
-
|${
|
|
28
|
-
if(!
|
|
19
|
+
cache.result,baselineCost:n.cost.baselineEstimate,actualCost:n.cost.actual,netSavings:n.cost.netSavings,...n.cost.basis?{basis:n.cost.basis}:{}}}};function B(){return globalThis.process}function Ue(e){if(/^[0-9a-f]{12}$/.test(e)||/^\d{1,3}(\.\d{1,3}){3}$/.test(e)||/^ip-\d{1,3}(-\d{1,3}){3}$/i.test(e))return true;let t="[bcdfghjklmnpqrstvwxz2456789]";
|
|
20
|
+
return new RegExp(`-${t}{8,10}-${t}{5}$`).test(e)}function qe(e){return !!(e.AWS_LAMBDA_FUNCTION_NAME??e.LAMBDA_TASK_ROOT??e.VERCEL??e.VERCEL_ENV??e.K_SERVICE??e.FUNCTION_TARGET??e.FUNCTIONS_WORKER_RUNTIME??
|
|
21
|
+
e.NETLIFY)}async function Ke(){if(B()?.execPath)try{let t=await import(["node","os"].join(":")),n=typeof t.hostname=="function"?t.hostname():"";return n.length>0?n:void 0}catch{return}}async function He(e,t=Ke){
|
|
22
|
+
if(e.KUBERNETES_SERVICE_HOST||qe(e))return;let n=e.HOSTNAME??e.COMPUTERNAME??await t();if(!(!n||Ue(n)))return n}async function Je(){let e=B();if(!e||!e.env)return;let t=e.env,n=await He(t),o=[e.platform,
|
|
23
|
+
e.arch,e.execPath,n,t.USER??t.USERNAME??t.LOGNAME,t.HOME??t.USERPROFILE].filter(r=>typeof r=="string"&&r.length>0);return o.length?o.join("|"):void 0}var We=["CI","CONTINUOUS_INTEGRATION","GITHUB_ACTI\
|
|
24
|
+
ONS","GITLAB_CI","CIRCLECI","TRAVIS","JENKINS_URL","BUILDKITE","TF_BUILD","TEAMCITY_VERSION","BITBUCKET_BUILD_NUMBER","CODEBUILD_BUILD_ID","HEROKU_TEST_RUN_ID","DRONE"];function V(){let e=B()?.env;if(!e)
|
|
25
|
+
return false;for(let t of We){let n=e[t];if(n&&n!=="false"&&n!=="0")return true}return false}async function Ge(e,t){let n=globalThis.crypto,o=new TextEncoder;if(n?.subtle)try{let i=await n.subtle.importKey("raw",
|
|
26
|
+
o.encode(e),{name:"HMAC",hash:"SHA-256"},!1,["sign"]),a=await n.subtle.sign("HMAC",i,o.encode(t));return [...new Uint8Array(a)].map(l=>l.toString(16).padStart(2,"0")).join("")}catch{}let r=2166136261,s=`${e}\
|
|
27
|
+
|${t}`;for(let i=0;i<s.length;i++)r^=s.charCodeAt(i),r=Math.imul(r,16777619);return (r>>>0).toString(16).padStart(8,"0")}async function F(e){let n=B()?.env?.BVSALA_SEAT_ID;if(n&&Ye.test(n))return n;let o=await Je();
|
|
28
|
+
if(!o)return;let r=await Ge(e,o);if(r)return `m-${r.slice(0,32)}`}var Ye=/^[A-Za-z0-9._:/-]{1,128}$/;var Q={"gpt-4.1":{inputPer1k:.002,outputPer1k:.008,cachedInputPer1k:5e-4},"gpt-4.1-mini":{inputPer1k:4e-4,outputPer1k:.0016,cachedInputPer1k:1e-4},"gpt-4.1-nano":{inputPer1k:1e-4,outputPer1k:4e-4,cachedInputPer1k:25e-6},
|
|
29
29
|
"gpt-4o":{inputPer1k:.0025,outputPer1k:.01,cachedInputPer1k:.00125},"gpt-4o-mini":{inputPer1k:15e-5,outputPer1k:6e-4,cachedInputPer1k:75e-6},"gpt-4-turbo":{inputPer1k:.01,outputPer1k:.03},"gpt-4":{inputPer1k:.03,
|
|
30
30
|
outputPer1k:.06},"gpt-3.5-turbo":{inputPer1k:5e-4,outputPer1k:.0015},"gpt-5":{inputPer1k:.00125,outputPer1k:.01,cachedInputPer1k:125e-6},"gpt-5-codex":{inputPer1k:.00125,outputPer1k:.01,cachedInputPer1k:125e-6},
|
|
31
31
|
"gpt-5-mini":{inputPer1k:25e-5,outputPer1k:.002,cachedInputPer1k:25e-6},"gpt-5-nano":{inputPer1k:5e-5,outputPer1k:4e-4,cachedInputPer1k:5e-6},"gpt-5.1-codex":{inputPer1k:.00125,outputPer1k:.01,cachedInputPer1k:125e-6},
|
|
@@ -41,68 +41,135 @@ able-5":{inputPer1k:.01,outputPer1k:.05,cachedInputPer1k:.001},"claude-sonnet-4"
|
|
|
41
41
|
"claude-3-5-sonnet":{inputPer1k:.003,outputPer1k:.015,cachedInputPer1k:3e-4},"claude-3-5-haiku":{inputPer1k:8e-4,outputPer1k:.004,cachedInputPer1k:8e-5},"claude-3-opus":{inputPer1k:.015,outputPer1k:.075,
|
|
42
42
|
cachedInputPer1k:.0015},"claude-3-haiku":{inputPer1k:25e-5,outputPer1k:.00125,cachedInputPer1k:3e-5},"gemini-3.7-flash":{inputPer1k:75e-5,outputPer1k:.00375,cachedInputPer1k:75e-6},"gemini-3.5-flash":{
|
|
43
43
|
inputPer1k:.0015,outputPer1k:.009,cachedInputPer1k:15e-5},"gemini-3.5-flash-lite":{inputPer1k:3e-4,outputPer1k:.0025,cachedInputPer1k:3e-5},"gemini-3.1-pro-preview":{inputPer1k:.002,outputPer1k:.012,cachedInputPer1k:2e-4},
|
|
44
|
-
"gemini-2.5-pro":{inputPer1k:.00125,outputPer1k:.01,cachedInputPer1k:125e-6},"gemini-2.5-flash":{inputPer1k:3e-4,outputPer1k:.0025,cachedInputPer1k:3e-5}};function
|
|
45
|
-
return
|
|
46
|
-
slice(
|
|
44
|
+
"gemini-2.5-pro":{inputPer1k:.00125,outputPer1k:.01,cachedInputPer1k:125e-6},"gemini-2.5-flash":{inputPer1k:3e-4,outputPer1k:.0025,cachedInputPer1k:3e-5}};function ve(e,t,n){if(!n)return;let o=e[n];if(o)
|
|
45
|
+
return o;let r=n.lastIndexOf("/"),s=r>=0?n.slice(r+1):n,i=e[s];if(i)return i;for(let a of t){if(!s.startsWith(a))continue;let l=s.charAt(a.length);if(l!==""&&l!=="-"&&l!=="."&&l!=="_")continue;let p=s.
|
|
46
|
+
slice(a.length+1);if(!(/^\d/.test(p)&&!/^\d{4}/.test(p)))return e[a]}}function X(e){return Object.keys(e).sort((t,n)=>n.length-t.length)}var Qe=X(Q);function M(e){return ve(Q,Qe,e)}var $=class{resto="";uso=null;eventos={};observar(t){this.resto+=typeof t=="string"?t:t.toString("utf8");let n;for(;(n=this.resto.indexOf(`
|
|
47
47
|
|
|
48
|
-
`))>=0;){let
|
|
49
|
-
let s=/^data: (.+)$/m.exec(
|
|
50
|
-
this.uso={...this.uso,...
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
48
|
+
`))>=0;){let o=this.resto.slice(0,n);this.resto=this.resto.slice(n+2);let r=/^event: (.+)$/m.exec(o)?.[1];if(r&&(this.eventos[r]=(this.eventos[r]??0)+1),r!=="message_start"&&r!=="message_delta")continue;
|
|
49
|
+
let s=/^data: (.+)$/m.exec(o)?.[1];if(s)try{let i=JSON.parse(s),a=i.usage??i.message?.usage;if(a){let l=a.cache_creation||this.uso?.cache_creation?{...this.uso?.cache_creation,...a.cache_creation}:void 0;
|
|
50
|
+
this.uso={...this.uso,...a,...l?{cache_creation:l}:{}};}}catch{}}}usage(){return this.uso}usageFinal(){return (this.eventos.message_delta??0)>0?this.uso:null}},U=class{resto="";uso=null;eventos={};observar(t){
|
|
51
|
+
this.resto+=typeof t=="string"?t:t.toString("utf8");let n;for(;(n=this.resto.indexOf(`
|
|
52
|
+
|
|
53
|
+
`))>=0;){let o=this.resto.slice(0,n);this.resto=this.resto.slice(n+2);let r=/^event: (.+)$/m.exec(o)?.[1];r&&(this.eventos[r]=(this.eventos[r]??0)+1);let s=/^data: (.+)$/m.exec(o)?.[1];if(s)try{let i=JSON.
|
|
54
|
+
parse(s);if((r??i.type)!=="response.completed")continue;let a=i.response?.usage;a&&(this.uso=Xe(a));}catch{}}}usage(){return this.uso}usageFinal(){return this.uso}};function Xe(e){let t=Math.max(0,e.input_tokens??
|
|
55
|
+
0),n=Math.min(Math.max(0,e.input_tokens_details?.cached_tokens??0),t),o=Math.min(Math.max(0,e.input_tokens_details?.cache_write_tokens??0),t-n);return {input_tokens:t-n-o,output_tokens:Math.max(0,e.output_tokens??
|
|
56
|
+
0),cache_read_input_tokens:n,cache_creation_input_tokens:o}}var ye=1.25,Ze=2;function ke(e,t){let n=M(e);if(!n)return null;let o=t.input_tokens??0,r=t.output_tokens??0,s=t.cache_read_input_tokens??0,i=t.
|
|
57
|
+
cache_creation,a=t.cache_creation_input_tokens??(i?.ephemeral_5m_input_tokens??0)+(i?.ephemeral_1h_input_tokens??0),l=Math.min(i?.ephemeral_1h_input_tokens??0,a),p=n.cachedInputPer1k??n.inputPer1k;return {
|
|
58
|
+
costeUsd:o/1e3*n.inputPer1k+s/1e3*p+(a-l)/1e3*n.inputPer1k*tt(e)+l/1e3*n.inputPer1k*Ze+r/1e3*n.outputPer1k,precio:n,tokens:{entrada:o,salida:r,cacheLeida:s,cacheEscrita:a,cacheEscrita1h:l}}}function xe(e,t){
|
|
59
|
+
try{let n=e,o={maxOutputLength:et};switch((t??"").trim().toLowerCase()){case "gzip":case "x-gzip":n=T.gunzipSync(e,o);break;case "deflate":n=T.inflateSync(e,o);break;case "br":n=T.brotliDecompressSync(e,o);
|
|
60
|
+
break;case "zstd":{let s=T.zstdDecompressSync;if(typeof s!="function")return "";n=s(e,o);break}default:break}let r=JSON.parse(n.toString("utf8")).model;return typeof r=="string"?r:""}catch{return ""}}var et=5*
|
|
61
|
+
1024*1024;function we(){return typeof T.zstdDecompressSync=="function"}function tt(e){let t=e.toLowerCase();return t.startsWith("gpt-")||t.startsWith("o1")||t.startsWith("o3")||t.startsWith("o4")?/^gpt-5\.6/.
|
|
62
|
+
test(t)?ye:1:t.startsWith("gemini")?1:ye}function Z(e){try{return JSON.parse(typeof e=="string"?e:e.toString("utf8")).usage??null}catch{return null}}function Se(e,t,n){return Ce(e,t,n,false)}function Te(e,t,n){
|
|
63
|
+
return Ce(e,t,n,true)}function Ce(e,t,n,o){let r=n.tokens.entrada+n.tokens.cacheLeida+n.tokens.cacheEscrita;return {requestId:e,model:t,calls:o?{avoidedLlm:1,actualLlm:0}:{avoidedLlm:0,actualLlm:1},tokens:{
|
|
64
|
+
baselineInput:r,actualInput:o?0:r,eliminatedInput:0,compressedInput:0,baselineOutput:n.tokens.salida,actualOutput:o?0:n.tokens.salida,providerCachedInput:o?0:n.tokens.cacheLeida},cache:{result:o?"sing\
|
|
65
|
+
leflight":"miss"},cost:o?{baselineEstimate:n.costeUsd,actual:0,netSavings:1,basis:"estimated"}:{baselineEstimate:n.costeUsd,actual:n.costeUsd,netSavings:0,basis:"measured"}}}var ee=5*1024*1024,ot=["authorization","x-api-key","anthropic-version","anthropic-beta"];function Re(e,t,n,o=""){return createHash("sha256").update(e).update(`
|
|
66
|
+
`).update(t).update(`
|
|
67
|
+
`).update(o).update(`
|
|
68
|
+
`).update(n).digest("hex")}function Ee(e){return ot.map(t=>`${t}=${e(t)??""}`).join(`
|
|
69
|
+
`)}var q=class{enVuelo=new Map;primarias=0;gemelas=0;registrar(t){if(this.enVuelo.has(t))return null;this.primarias+=1;let n,o=new Promise(a=>n=a);this.enVuelo.set(t,{promesa:o,resolver:n});let r=[],s=0,
|
|
70
|
+
i=false;return {capturar:a=>{if(!i){if(s+=a.length,s>ee){i=true,r.length=0;return}r.push(a);}},terminar:(a,l)=>{let p=!i&&a>=200&&a<300;this.enVuelo.get(t)?.resolver(p?{status:a,headers:l,chunks:r}:null),this.
|
|
71
|
+
enVuelo.delete(t);},abortar:()=>{this.enVuelo.get(t)?.resolver(null),this.enVuelo.delete(t);}}}async esperar(t){let n=this.enVuelo.get(t);if(!n)return null;let o=await n.promesa;return o&&(this.gemelas+=
|
|
72
|
+
1),o}tieneEnVuelo(t){return this.enVuelo.has(t)}};var it=["content-encoding","content-length","transfer-encoding"];async function Ie(e={}){let t=(e.upstream??"https://api.anthropic.com").replace(/\/+$/,""),n=e.fetchImpl??fetch,o=e.log??(c=>console.log(
|
|
73
|
+
c)),r;if(e.serviceAccountKey){r=new I({url:e.meteringUrl??"https://savings.bivelio.com/api/metering/usage",serviceAccountKey:e.serviceAccountKey,...e.fetchImpl?{fetchImpl:e.fetchImpl}:{},onAuthError:u=>w(
|
|
74
|
+
"gateway-ingest-auth",`[bvsala gateway] la ingesta de uso rechaza la credencial (${u}). El tr\xE1fico sigue fluyendo (avisar, nunca romper), pero este uso no est\xE1 llegando al panel. Revisa BIVELIO_SERVIC\
|
|
75
|
+
E_KEY y el estado de la suscripci\xF3n en savings.bivelio.com.`)});let c=V();e.seatId?r.setSeat(e.seatId,c):F(e.serviceAccountKey).then(u=>r?.setSeat(u,c)).catch(()=>{}),r.start();}else w("gateway-sin-\
|
|
76
|
+
clave","[bvsala gateway] sin BIVELIO_SERVICE_KEY: el gateway reenv\xEDa el tr\xE1fico pero NO mide. Consigue tu clave en savings.bivelio.com/dashboard/install para ver el gasto en tu panel.");let s=e.
|
|
77
|
+
coalescer?new q:null,i=c=>c==="/v1/messages"?new $:c.endsWith("/responses")?new U:null,a=(c,u,f,b,m,d)=>{if(!r||!f)return;let h=xe(u,d);if(!h){w(`gateway-sin-modelo:${m}`,`[bvsala gateway] no se pudo \
|
|
78
|
+
leer el modelo del cuerpo de ${m} (content-encoding: ${d??"ninguno"}); su uso se reenv\xEDa pero no se mide.`);return}let k=ke(h,f);k?(r.record(b?Te(c,h,k):Se(c,h,k)),o(`[gateway] POST ${m} ${b?"\xB7 COA\
|
|
79
|
+
LESCIDA (llamada evitada)":""} \xB7 ${h} \xB7 $${k.costeUsd.toFixed(6)} (in ${k.tokens.entrada}, cache\u2191${k.tokens.cacheEscrita}${k.tokens.cacheEscrita1h?` (1h ${k.tokens.cacheEscrita1h})`:""} cac\
|
|
80
|
+
he\u2193${k.tokens.cacheLeida}, out ${k.tokens.salida})`)):w(`gateway-sin-precio:${h}`,`[bvsala gateway] el modelo "${h}" no est\xE1 en el cat\xE1logo de precios; su uso se reenv\xEDa pero no se mide.`);},
|
|
81
|
+
l=createServer(async(c,u)=>{let f=randomUUID(),b=new AbortController;u.on("close",()=>{u.writableEnded||b.abort();});let m=null,d=new URL(c.url??"/",t),h=null,k=Buffer.alloc(0),N=false,O=c.headers["content-encoding"],z=Array.
|
|
82
|
+
isArray(O)?O[0]:O;try{let D=[];for await(let g of c)D.push(g);let S=Buffer.concat(D);k=S,d=new URL(c.url??"/",t);let pe=s!==null&&c.method==="POST"&&S.length>0,Ve=Ee(g=>{let v=c.headers[g];return Array.
|
|
83
|
+
isArray(v)?v.join(", "):v}),W=pe?Re("POST",d.pathname+d.search,S,Ve):"";if(pe)if(s.tieneEnVuelo(W)){let g=await s.esperar(W);if(g){u.writeHead(g.status,g.headers);let v=i(d.pathname)??new $;for(let ge of g.
|
|
84
|
+
chunks)u.write(ge),v.observar(ge);u.end();let Fe=v.usage()??(d.pathname==="/v1/messages"?Z(Buffer.concat(g.chunks)):null);a(f,S,Fe,!0,d.pathname,z);return}}else m=s.registrar(W);let me={};for(let[g,v]of Object.
|
|
85
|
+
entries(c.headers))g==="host"||g==="content-length"||v===void 0||(me[g]=Array.isArray(v)?v.join(", "):v);let x;try{x=await n(d,{method:c.method,headers:me,body:["GET","HEAD"].includes(c.method??"")?void 0:
|
|
86
|
+
S,redirect:"manual",signal:b.signal});}catch(g){m?.abortar(),u.headersSent||(u.writeHead(502,{"content-type":"application/json"}),u.end(JSON.stringify({error:"bvsala gateway: upstream unreachable",detail:String(
|
|
87
|
+
g)}))),o(`[gateway] ${c.method} ${d.pathname} \u2192 upstream inalcanzable: ${String(g)}`);return}let G=Object.fromEntries(x.headers.entries());for(let g of it)delete G[g];u.writeHead(x.status,G);let fe=x.
|
|
88
|
+
headers.get("content-type")??"",Y=fe.includes("text/event-stream")||fe===""&&x.ok&&i(d.pathname)!==null;h=Y?i(d.pathname):null;let L=!Y&&x.ok&&r!==void 0&&c.method==="POST"&&d.pathname==="/v1/messages"&&
|
|
89
|
+
(x.headers.get("content-type")??"").includes("application/json")?[]:null,he=0;if(x.body)for await(let g of x.body){let v=Buffer.from(g);u.write(v),h?.observar(v),L&&(he+=v.length,he>ee?L=null:L.push(v)),
|
|
90
|
+
m?.capturar(v);}u.end(),m?.terminar(x.status,G),h&&x.ok?(N=!0,a(f,S,h.usage(),!1,d.pathname,z)):L?a(f,S,Z(Buffer.concat(L)),!1,d.pathname,z):Y||o(`[gateway] ${c.method} ${d.pathname} \u2192 ${x.status}`);}catch(D){
|
|
91
|
+
if(m?.abortar(),b.signal.aborted){let S=!N&&h?h.usageFinal():null;S?(o(`[gateway] ${c.method} ${d.pathname} \u2192 el cliente cerr\xF3 tras el usage final; se mide igual`),a(f,k,S,false,d.pathname,z)):o(
|
|
92
|
+
`[gateway] ${c.method} ${d.pathname} \u2192 el cliente abort\xF3; upstream cancelado`);}else if(o(`[gateway] ${c.method} ${d.pathname} \u2192 error sirviendo: ${String(D)}`),!u.headersSent){u.writeHead(
|
|
93
|
+
502,{"content-type":"application/json"}),u.end(JSON.stringify({error:"bvsala gateway: stream interrupted",detail:String(D)}));return}u.writableEnded||u.end();}});return l.on("upgrade",(c,u)=>{o(`[gatew\
|
|
94
|
+
ay] ${c.method} ${new URL(c.url??"/","http://x").pathname} \u2192 426 (WebSocket no soportado; el cliente cae a HTTP)`),u.once("error",()=>{}),u.once("finish",()=>u.destroy()),u.end(`HTTP/1.1 426 Upgr\
|
|
95
|
+
ade Required\r
|
|
96
|
+
connection: close\r
|
|
97
|
+
content-length: 0\r
|
|
98
|
+
\r
|
|
99
|
+
`);}),await new Promise((c,u)=>{l.once("error",u),l.listen(e.puerto??8402,"127.0.0.1",()=>c());}),{puerto:l.address().port,cerrar:async()=>{await r?.stopAndDrain(),await new Promise(c=>l.close(()=>c()));}}}var te=["claude","codex","goose","qwen"];function _e(e){return te.includes(e??"")}function Ae(e,t={}){let n=t.path??process.env.PATH??"",o=t.
|
|
100
|
+
existe??existsSync,r=null;for(let s of n.split(";").filter(i=>i.length>0)){for(let i of [".exe",".com"]){let a=join(s,e+i);if(o(a))return {tipo:"exe",ruta:a}}if(r===null)for(let i of [".cmd",".bat"]){let a=join(s,e+
|
|
101
|
+
i);if(o(a)){r=a;break}}}return r!==null?{tipo:"solo-shim",ruta:r}:{tipo:"no-encontrado"}}function Oe(e={}){let n=(e.env??process.env).BVSALA_CODEX_AUTH?.toLowerCase();if(n==="api-key"||n==="apikey")return "\
|
|
102
|
+
api-key";if(n==="chatgpt")return "chatgpt";let o=null;try{o=e.leerAuth?e.leerAuth():null;}catch{o=null;}if(o===null)return "api-key";try{let r=JSON.parse(o);return typeof r.auth_mode=="string"?r.auth_mode.
|
|
103
|
+
toLowerCase().replace(/[^a-z]/g,"")==="apikey"?"api-key":"chatgpt":r.tokens!==void 0&&r.tokens!==null?"chatgpt":"api-key"}catch{return "api-key"}}var ct={"api-key":{upstream:"https://api.openai.com",prefijo:"\
|
|
104
|
+
/v1"},chatgpt:{upstream:"https://chatgpt.com",prefijo:"/backend-api/codex"}};function ne(e,t,n,o={}){let r=`http://127.0.0.1:${t}`;switch(e){case "claude":return {bin:"claude",args:n,env:{ANTHROPIC_BASE_URL:r}};case "\
|
|
105
|
+
goose":return {bin:"goose",args:n,env:{ANTHROPIC_HOST:r}};case "qwen":return {bin:"qwen",args:n,env:{ANTHROPIC_BASE_URL:r},nota:'Qwen Code solo enruta por aqu\xED con `security.auth.selectedType: "anthropic\
|
|
106
|
+
"` en ~/.qwen/settings.json (ver `bvsala doctor`).'};case "codex":{let s=o.modoCodex??"api-key",i=ct[s];return {bin:"codex",args:["-c",`openai_base_url="${r}${i.prefijo}"`,...n],env:{},upstream:i.upstream,
|
|
107
|
+
nota:s==="chatgpt"?"Auth ChatGPT detectada: el gateway enruta a chatgpt.com/backend-api/codex. El tr\xE1fico de suscripci\xF3n se mide (equivalente a precio de lista) pero no se monetiza: no hay factura por\
|
|
108
|
+
tokens.":"Auth por API key: el gateway enruta a api.openai.com/v1."}}}}var pt="comprobar: en el log del gateway debe aparecer `[gateway] POST /v1/messages \u2026`; si ve\
|
|
109
|
+
s `/v1/v1/messages`, sobra un /v1; si ves `POST /messages` a secas, falta uno.",mt=[{id:"claude",nombre:"Claude Code",binario:"claude",configs:[],detectable:true,receta:({base:e})=>["usa `bvsala claude`\
|
|
110
|
+
\u2014 arranca el gateway en un puerto ef\xEDmero, lanza `claude` apunt\xE1ndole y lo apaga al salir.",`a mano: ANTHROPIC_BASE_URL=${e} claude (sin /v1: el cliente a\xF1ade /v1/messages)`]},{id:"co\
|
|
111
|
+
dex",nombre:"Codex CLI",binario:"codex",configs:[join(".codex","config.toml"),join(".codex","auth.json")],detectable:true,receta:({base:e,rutas:t})=>["usa `bvsala codex` \u2014 el prefijo del base URL depende del \
|
|
112
|
+
modo de auth y lo resuelve el envoltorio:",` API key \u2192 openai_base_url=${e}/v1`,` suscripci\xF3n ChatGPT \u2192 openai_base_url=${e}/backend-api/codex (tiene que terminar exactament\
|
|
113
|
+
e ah\xED)`,`${t[1]}: solo se comprueba que exista; nunca se lee.`]},{id:"opencode",nombre:"opencode",binario:"opencode",configs:[join(".config","opencode","opencode.json")],detectable:true,receta:({baseV1:e,
|
|
114
|
+
rutas:t})=>[`mecanismo: ${t[0]} \u2192 provider.anthropic.options.baseURL (el opencode.json del proyecto pisa al global). CON /v1.`,"{",' "provider": {',' "anthropic": {',` "options": { "base\
|
|
115
|
+
URL": "${e}" }`," }"," }","}",'alternativa: "baseURL": "{env:BVSALA_BASE_URL}" y exportar la variable; trampa: sin la variable, baseURL queda vac\xEDo.']},{id:"goose",nombre:"Goose",binario:"goose",
|
|
116
|
+
configs:[join(".config","goose","config.yaml")],detectable:true,receta:({base:e,rutas:t})=>[`mecanismo: variable de entorno ANTHROPIC_HOST (pisa ${t[0]}). SIN /v1.`,`ANTHROPIC_HOST=${e} ANTHROPIC_API_KEY=\u2026\
|
|
117
|
+
goose`]},{id:"crush",nombre:"Crush",binario:"crush",configs:[join(".config","crush","crushrc")],detectable:true,receta:({baseV1:e,rutas:t})=>[`mecanismo: ${t[0]} (Bash); crush.json est\xE1 deprecado. CON /v1\
|
|
118
|
+
y SIN barra final (el SDK Go resuelve relativo: /v1/ \u2192 /v1/v1/messages).`,`provider add bvsala --type anthropic --base-url ${e} --api-key "$ANTHROPIC_API_KEY"`]},{id:"qwen",nombre:"Qwen Code",binario:"\
|
|
119
|
+
qwen",configs:[join(".qwen","settings.json")],detectable:true,receta:({base:e,rutas:t})=>[`mecanismo: variable de entorno ANTHROPIC_BASE_URL + ${t[0]} con selectedType = anthropic. SIN /v1 (el SDK TS ofici\
|
|
120
|
+
al a\xF1ade /v1/messages).`,`ANTHROPIC_BASE_URL=${e} ANTHROPIC_API_KEY=\u2026 qwen`,`comprueba a mano que ${t[0]} lleve selectedType: anthropic \u2014 el doctor no lee el archivo.`]},{id:"droid",nombre:"\
|
|
121
|
+
Droid (Factory)",binario:"droid",configs:[join(".factory","settings.json")],detectable:true,receta:({base:e,rutas:t})=>[`mecanismo: ${t[0]} \u2192 customModels[] con provider "anthropic". SIN /v1 [inferido: val\
|
|
122
|
+
idar en mano contra el log del gateway].`,"{",' "customModels": ['," {",' "model": "<id de modelo Anthropic>",',' "displayName": "<nombre visible>",',' "provider": "anthropic",',` \
|
|
123
|
+
"baseUrl": "${e}",`,' "apiKey": "<tu API key de Anthropic>"'," }"," ]","}"]},{id:"cline",nombre:"Cline",configs:[],detectable:false,receta:({baseV1:e})=>["mecanismo: UI \u2192 proveedor Anthropic\
|
|
124
|
+
\u2192 marca \xABUse custom base URL\xBB. CON /v1 (Cline fija https://api.anthropic.com/v1 y solo normaliza la URL oficial exacta).",`Base URL: ${e}`]},{id:"kilo",nombre:"Kilo Code",configs:[],detectable:false,
|
|
125
|
+
receta:({baseV1:e})=>["mecanismo: UI \u2192 proveedor custom, Provider API \xABAnthropic Messages\xBB. Probar CON /v1 primero (linaje Cline) [inferido: validar en mano].",`Base URL: ${e}`]}];function ft(e,t){
|
|
126
|
+
let n=e.split(delimiter).filter(r=>r.length>0),o=process.platform==="win32"?["",".exe",".cmd",".bat"]:[""];return r=>n.some(s=>o.some(i=>t(join(s,r+i))))}async function De(e,t={}){if(!Number.isInteger(e)||e<1||
|
|
127
|
+
e>65535)throw new RangeError(`[bvsala doctor] puerto inv\xE1lido: ${String(e)}`);let n=t.home??homedir(),o=t.existe??existsSync,r=t.enPath??ft(t.path??process.env.PATH??"",o),s=`http://127.0.0.1:${e}`,i=`${s}/v1`,
|
|
128
|
+
a=[],l=[`bvsala doctor \u2014 gateway local en ${s}`,"Solo imprime: no escribe ninguna configuraci\xF3n. Detecci\xF3n por existencia (binarios en PATH y","archivos de configuraci\xF3n de usuario); nunca \
|
|
129
|
+
se lee el contenido de ning\xFAn archivo.",""];for(let p of mt){let c=p.configs.map(d=>join(n,d)),u=p.binario!==void 0&&r(p.binario),f=c.filter(d=>o(d)),b=u||f.length>0;b&&a.push(p.id);let m;p.detectable?
|
|
130
|
+
b?m=`detectado \u2014 ${[...u?[`binario \`${p.binario}\` en PATH`]:[],...f.map(h=>`config presente: ${h}`)].join("; ")}`:m="no detectado":m="sin detecci\xF3n autom\xE1tica (extensi\xF3n de editor)",l.
|
|
131
|
+
push(`\xBB ${p.id}: ${m}`),l.push(` ${p.nombre}`);for(let d of p.receta({base:s,baseV1:i,rutas:c}))l.push(` ${d}`);l.push(` ${pt}`),l.push("");}return l.push("Regla BYOK: las recetas del anillo Anth\
|
|
132
|
+
ropic (Goose, Qwen Code, opencode, Crush, Droid, Cline, Kilo) son","para API key. Nunca enrutes por el gateway el OAuth de una suscripci\xF3n Claude Pro/Max desde un harness","de terceros \u2014 Anthropic \
|
|
133
|
+
lo proh\xEDbe. Claude Code y Codex con suscripci\xF3n se enrutan y se miden, pero no","se monetizan: no hay factura por tokens.","Detalle por terminal: docs/terminales/<terminal>.md"),{texto:l.join(`
|
|
134
|
+
`),detectados:a}}var oe=class{id;exact=false;constructor(t="heuristic"){this.id=t;}count(t){if(t.length===0)return 0;let n=t.match(/[A-Za-z]+|\d+|\s+|[^\sA-Za-z\d]/g)??[],o=0;for(let r of n){let s=r[0];if(/\s/.test(s)){let i=(r.
|
|
135
|
+
match(/\n/g)??[]).length;o+=i+Math.floor(Math.max(0,r.length-1)/2);}else /[A-Za-z]/.test(s)?o+=Math.max(1,Math.ceil(r.length/4)):/\d/.test(s)?o+=Math.max(1,Math.ceil(r.length/2)):o+=1;}return Math.max(o,
|
|
136
|
+
Math.ceil(t.length/4))}},re=new oe;var se=class{estimators=new Map;modelToTokenizer=new Map;fallbackId;constructor(t=re){this.fallbackId=t.id,this.estimators.set(t.id,t);}register(t){return this.estimators.set(t.id,t),this}mapModel(t,n){
|
|
137
|
+
return this.modelToTokenizer.set(t,n),this}resolve(t){let n=t.tokenizer?this.estimators.get(t.tokenizer):void 0;if(n)return n;if(t.model){let o=this.modelToTokenizer.get(t.model);if(o){let r=this.estimators.
|
|
138
|
+
get(o);if(r)return r}}return this.estimators.get(this.fallbackId)}count(t,n){return this.resolve(n).count(t)}};new se;new TextEncoder;new TextDecoder;new TextDecoder;var _t={"gpt-4o-mini":{deltaTokens:-16,salidaDelBrazoA:72,contrato:"triage (6 fields, long enums)",pares:8},"gpt-4o":{deltaTokens:3,salidaDelBrazoA:75,contrato:"triage (6 fields, long enums)",pares:8}};
|
|
139
|
+
X(_t);var le="0.4.6";var[,,y,...A]=process.argv;function Mt(){console.log(`bvsala ${le} \u2014 BiVelio Savings Layer
|
|
89
140
|
|
|
90
141
|
Comandos:
|
|
91
|
-
gateway [--puerto N] Proxy local medidor para
|
|
142
|
+
gateway [--puerto N] Proxy local medidor (por defecto 8402) para terminales que
|
|
143
|
+
respetan un base URL: Claude Code, opencode, Goose, Crush\u2026
|
|
92
144
|
claude [args\u2026] Lanza Claude Code a trav\xE9s del gateway, sin configurar nada.
|
|
145
|
+
codex [args\u2026] Lanza Codex CLI a trav\xE9s del gateway (API key: api.openai.com;
|
|
146
|
+
auth ChatGPT: chatgpt.com/backend-api/codex \u2014 se mide, no se monetiza).
|
|
147
|
+
goose [args\u2026] Lanza Goose (ANTHROPIC_HOST) a trav\xE9s del gateway.
|
|
148
|
+
qwen [args\u2026] Lanza Qwen Code (ANTHROPIC_BASE_URL) a trav\xE9s del gateway.
|
|
149
|
+
doctor [--puerto N] Detecta terminales instalados e imprime su configuraci\xF3n exacta.
|
|
93
150
|
|
|
94
151
|
Entorno:
|
|
95
152
|
BIVELIO_SERVICE_KEY Clave de servicio (bvk_\u2026): habilita metering + asiento.
|
|
96
153
|
BVSALA_SEAT_ID Identidad de asiento estable por r\xE9plica (opcional).
|
|
97
|
-
BVSALA_GATEWAY_UPSTREAM Upstream alternativo (por defecto
|
|
154
|
+
BVSALA_GATEWAY_UPSTREAM Upstream alternativo (por defecto el del terminal).
|
|
98
155
|
BVSALA_GATEWAY_COALESCE =1 enciende la coalescencia de duplicados (F2, experimental).
|
|
156
|
+
BVSALA_CODEX_AUTH api-key | chatgpt \u2014 fuerza el modo de auth de Codex.
|
|
157
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 (Claude Code, solo API key) tus modelos en /model.
|
|
99
158
|
|
|
100
159
|
El gateway nunca corta el tr\xE1fico: sin clave o con la ingesta ca\xEDda, reenv\xEDa y avisa.
|
|
101
|
-
Tus prompts jam\xE1s salen de tu m\xE1quina \u2014 solo cuentas y coste. M\xE1s: savings.bivelio.com`);}
|
|
102
|
-
indexOf("--puerto"),
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
160
|
+
Tus prompts jam\xE1s salen de tu m\xE1quina \u2014 solo cuentas y coste. M\xE1s: savings.bivelio.com`);}var J=_e(y);y!=="gateway"&&y!=="doctor"&&!J&&(Mt(),process.exit(y===void 0||y==="help"||y==="--he\
|
|
161
|
+
lp"?0:1));var ue=A.indexOf("--puerto"),_=J?0:ue>=0?Number(A[ue+1]):8402;(!Number.isInteger(_)||(y==="doctor"?_<1:_<0)||_>65535)&&(console.error(`[bvsala ${y}] puerto inv\xE1lido: ${A[ue+1]}`),process.
|
|
162
|
+
exit(1));if(y==="doctor"){A.includes("--write")&&(console.error("[bvsala doctor] --write todav\xEDa no existe: el doctor solo imprime; copia el bloque a mano."),process.exit(1));let e=await De(_);console.
|
|
163
|
+
log(e.texto),process.exit(0);}var R=J?ne(y,0,A,{modoCodex:y==="codex"?Oe({leerAuth:()=>readFileSync(join(homedir(),".codex","auth.json"),"utf8")}):void 0}):null,de=process.env.BIVELIO_SERVICE_KEY,E=await Ie({puerto:_,...de?
|
|
164
|
+
{serviceAccountKey:de}:{},...process.env.BVSALA_SEAT_ID?{seatId:process.env.BVSALA_SEAT_ID}:{},...process.env.BVSALA_GATEWAY_UPSTREAM?{upstream:process.env.BVSALA_GATEWAY_UPSTREAM}:R?.upstream?{upstream:R.
|
|
165
|
+
upstream}:{},...process.env.BVSALA_GATEWAY_COALESCE==="1"?{coalescer:true}:{},...process.env.BVSALA_METERING_URL?{meteringUrl:process.env.BVSALA_METERING_URL}:{}}),$t=process.env.BVSALA_GATEWAY_UPSTREAM??
|
|
166
|
+
R?.upstream??"https://api.anthropic.com";console.log(`[bvsala gateway] v${le} escuchando en http://127.0.0.1:${E.puerto} \u2192 ${$t}`);console.log(`[bvsala gateway] medici\xF3n: ${de?"ACTIVA (clave d\
|
|
167
|
+
e servicio presente)":"APAGADA (falta BIVELIO_SERVICE_KEY)"}`);process.env.BVSALA_GATEWAY_UPSTREAM&&R?.upstream&&process.env.BVSALA_GATEWAY_UPSTREAM!==R.upstream&&console.error(`[bvsala ${y}] AVISO: B\
|
|
168
|
+
VSALA_GATEWAY_UPSTREAM=${process.env.BVSALA_GATEWAY_UPSTREAM} pisa el upstream de este terminal (${R.upstream}). Si no es lo que quieres, quita la variable.`);y==="codex"&&!we()&&console.error(`[bvsal\
|
|
169
|
+
a codex] AVISO: este Node (${process.version}) no descomprime zstd y Codex manda el cuerpo en zstd: el tr\xE1fico pasa pero NO se mide. Usa Node \u2265 22.15.`);if(R&&J){let e=ne(y,E.puerto,A,{...R.upstream?
|
|
170
|
+
{modoCodex:R.upstream.includes("chatgpt.com")?"chatgpt":"api-key"}:{}});e.nota&&console.log(`[bvsala ${y}] ${e.nota}`);let t=e.bin;if(process.platform==="win32"){let o=Ae(e.bin);o.tipo==="exe"?t=o.ruta:
|
|
171
|
+
o.tipo==="solo-shim"&&(console.error(`[bvsala ${y}] \`${e.bin}\` est\xE1 instalado como shim de npm (${o.ruta}) y Windows no puede lanzarlo de forma segura con argumentos (CVE-2024-27980). Inst\xE1lalo c\
|
|
172
|
+
on su instalador nativo (deja un ${e.bin}.exe en el PATH) o usa la receta manual de docs/terminales/.`),await E.cerrar(),process.exit(127));}let n=spawn(t,e.args,{stdio:"inherit",env:{...process.env,...e.
|
|
173
|
+
env}});n.on("error",o=>{console.error(`[bvsala ${y}] no se pudo lanzar \`${e.bin}\`: ${String(o)}. \xBFEst\xE1 instalado y en el PATH?`),E.cerrar().then(()=>process.exit(127));}),n.on("exit",(o,r)=>{E.
|
|
174
|
+
cerrar().then(()=>process.exit(o??(r?1:0)));});}else console.log(`[bvsala gateway] apunta tu terminal (o usa \`bvsala ${te.join("|")}\`): ANTHROPIC_BASE_URL=http://127.0.0.1:${E.puerto} claude \xB7 bv\
|
|
175
|
+
sala doctor para el resto`);var Be=async()=>{await E.cerrar(),process.exit(0);};process.on("SIGINT",()=>{Be();});process.on("SIGTERM",()=>{Be();});
|
package/dist/index.d.ts
CHANGED
|
@@ -3237,6 +3237,6 @@ declare function createSavingsLayer(config: Omit<SavingsLayerConfig, "tokenizer"
|
|
|
3237
3237
|
* constant to package.json so the two can never drift.
|
|
3238
3238
|
*/
|
|
3239
3239
|
|
|
3240
|
-
declare const BV_SALA_VERSION = "0.4.
|
|
3240
|
+
declare const BV_SALA_VERSION = "0.4.6";
|
|
3241
3241
|
|
|
3242
3242
|
export { type AnthropicOptions, AnthropicProvider, BV_SALA_VERSION, type BivelioHandoff, CATALOGO, COMPACT_ROWS_INSTRUCTION, type CacheOutcome, type CacheTier, type CachedAnswer, type CanonicalRequest, type ChatMessage, type CompactRowsDecodeOptions, type CompactRowsOptions, type CompactVerification, type CompressionEconomics, type CompressionPolicyInput, type CompressionRequest, type CompressionResult, type Compressor, type ConstraintBackend, type ConstraintBackendId, type ConstraintPlan, type ConversationState, type CostBasis, type CostModel, DEFAULT_COST_MODEL, DEFAULT_MAX_STALENESS_SECONDS, type DeferredExecutionReport, type DeterministicResolver, EMBEDDED_PUBLIC_KEY, type EmbeddingFn, type EvidenceDoc, type ExactCache, ExactTokenizer, type ExecutionClass, ExecutionRouter, type FormatChoice, FormatSelector, type FormatSelectorOptions, type HandoffFact, HeuristicEstimator, InMemoryExactCache, InMemorySemanticCache, type JsonSchema, LicenseError, type LicenseGate, type LicenseKeyset, type LicenseStatus, type LicenseTokenPayload, type LocalClassifier, MODELOS_POR_CODIFICACION, MODELS_BY_ENCODING, type MemoryDelta, type MessageRole, MockProvider, type MockProviderOptions, type ModelCascadeMode, type ModelCascadeReport, type ModelCascadeSettings, type OpenAICompatibleOptions, OpenAICompatibleProvider, type OutputContract, PRECIOS_AL_DIA, type Policy, type ProtectedSpan, type Provider, type ProviderRequest, type ProviderResponse, type ProviderUsage, QUALITY_STATUSES, type QualityStatus, type RequestContext, type Reranker, type ResolvedDeps, type ResolvedGate, type ResolvedLicense, ResolverRegistry, type ResolverResult, type RetrievalOptions, type RetrievalOutcome, type RetrievalPlan, type Retriever, type RevocationListBody, type RevocationListVerification, type RevocationPollEvent, RevocationPoller, type RevocationPollerOptions, RevocationSnapshot, type RouteDecision, type SavingsLayer, type SavingsLayerConfig, SavingsLedger, type SavingsReport, type SavingsRequest, type SavingsResult, type SavingsToggles, SchemaRegistry, type SemanticCacheOptions, type SemanticHit, type SemanticRecord, type SemanticVerifier, type SerializationFormat, type Serializer, Singleflight, type TokenEstimator, TokenizerRegistry, type ToolCallRequest, type ToolDescriptor, type ToolRisk, type ToolSelection, ToolSelector, type ToolSelectorOptions, TraceCollector, type TraceEvent, UMBRAL_LOCAL, UsageEmitter, type UsageEmitterOptions, type ValidationError, type ValidationResult, adaptiveK, arithmeticResolver, buildExactKey, buildInflightKey, canonicalize, compactRowsSerializer, compresionFiel, computeNextDelay, cosine, createCompactRowsSerializer, createDefaultResolvers, createHandoff, createMockProvider, createSavingsLayer, csvSerializer, decodeCompactRows, dedupe, defaultTokenizerRegistry, deriveSeatId, deriveStatus, detectCi, embebedorLocal, emptyState, encodeCompactRows, evalArithmetic, evaluatePolicy, executeRetrievalPlan, gateFromKey, heuristicEstimator, isKeyset, isStrictCompatible, jsonCompactSerializer, keyTokensMatch, liveGateFromKey, mergeDelta, modelFamilyOf, multisetEqual, negationCount, noopCompressor, normalizeText, numericTokens, precioDeLista, projectContext, providerNativeBackend, randomId, registerExactTokenizer, registrarTokenizadorExacto, renderState, resolveConstraintBackend, resolveDependencies, resolveLicense, sha256, shouldCompress, stableStringify, uniformColumns, validateAgainstSchema, vectorLocal, verifyCompactSignature, verifyLicenseToken, verifyRevocationList, withProtectedSpans, words };
|
package/dist/index.js
CHANGED
|
@@ -781,7 +781,7 @@ ng/usage`:void 0);if(a){i=new dt({url:a,serviceAccountKey:t.serviceAccountKey,..
|
|
|
781
781
|
{},...t.metering?.maxBatch!=null?{maxBatch:t.metering.maxBatch}:{},...t.metering?.maxBuffer!=null?{maxBuffer:t.metering.maxBuffer}:{},...t.meteringFetch?{fetchImpl:t.meteringFetch}:t.licenseFetch?{fetchImpl:t.
|
|
782
782
|
licenseFetch}:{},...t.now?{now:t.now}:{},...t.metering?.ci!=null?{ci:t.metering.ci}:{},...t.silenceWarnings!=null?{silenceWarnings:t.silenceWarnings}:{}});let c=t.metering?.ci??cr();if(t.metering?.seatId)
|
|
783
783
|
i.setSeat(t.metering.seatId,c);else {let l=i;lr(t.serviceAccountKey).then(u=>l.setSeat(u,c)).catch(()=>{});}i.start();}}return {generate:async a=>{let c=await s.generate(a);if(i)try{i.record(c.report);}catch{}
|
|
784
|
-
return c},close:()=>{r?.stop(),o?.stop(),i?.stop();}}}var af="0.4.
|
|
784
|
+
return c},close:()=>{r?.stop(),o?.stop(),i?.stop();}}}var af="0.4.6";/*! Bundled license information:
|
|
785
785
|
|
|
786
786
|
@noble/curves/utils.js:
|
|
787
787
|
@noble/curves/abstract/modular.js:
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Guías por terminal — medir el gasto de IA con el gateway local
|
|
2
|
+
|
|
3
|
+
**Fecha:** 2026-08-30 · **Fuente de verdad:** `docs/PASARELA-S3-TERMINALES.md`
|
|
4
|
+
§3 (formas de URL y mecanismos de enrutado verificados contra el código de cada
|
|
5
|
+
terminal). Cada afirmación va marcada **[V]** (verificada en fuente) o **[I]**
|
|
6
|
+
(inferida). Si una guía contradice a ese documento, manda el documento.
|
|
7
|
+
|
|
8
|
+
## Qué es esto
|
|
9
|
+
|
|
10
|
+
`bvsala gateway` es un proxy local medidor: escucha en `127.0.0.1`, reenvía
|
|
11
|
+
cada petición **byte a byte** al proveedor (`https://api.anthropic.com` por
|
|
12
|
+
defecto) y, una vez servida la respuesta, lee el `usage` del stream y lo
|
|
13
|
+
convierte en coste. Tus prompts jamás salen de tu máquina hacia BiVelio — solo
|
|
14
|
+
cuentas y coste. Nunca corta el tráfico: sin clave o con la ingesta caída,
|
|
15
|
+
reenvía y avisa.
|
|
16
|
+
|
|
17
|
+
Hoy **mide**; no optimiza todavía. Y mide **todo el tráfico de inferencia**
|
|
18
|
+
que el terminal enruta por el base URL — no «todo el tráfico»: cada guía dice
|
|
19
|
+
lo que su terminal manda por fuera.
|
|
20
|
+
|
|
21
|
+
## Índice
|
|
22
|
+
|
|
23
|
+
| Guía | Base URL exacto | `/v1` | Enrutado [V] | Envoltorio | Estado |
|
|
24
|
+
|---|---|---|---|---|---|
|
|
25
|
+
| [Claude Code](claude-code.md) | `http://127.0.0.1:8402` | **sin** | env `ANTHROPIC_BASE_URL` | `bvsala claude` ✅ | [V] en producción |
|
|
26
|
+
| [Codex CLI](codex.md) | `http://127.0.0.1:8402/v1` (API key) · `…/backend-api/codex` (ChatGPT) | **con** | `codex -c openai_base_url=…` | `bvsala codex` ✅ | [V] validado en mano el 30-08 (426 → fallback HTTP, stream íntegro) |
|
|
27
|
+
| [Goose](goose.md) | `http://127.0.0.1:8402` | **sin** | env `ANTHROPIC_HOST` | `bvsala goose` ✅ | [V] |
|
|
28
|
+
| [Qwen Code](qwen-code.md) | `http://127.0.0.1:8402` | **sin** | env `ANTHROPIC_BASE_URL` + `~/.qwen/settings.json` | `bvsala qwen` ✅ | [V] |
|
|
29
|
+
| [opencode](opencode.md) | `http://127.0.0.1:8402/v1` | **con** | `~/.config/opencode/opencode.json` | — (config de usuario) | [V] |
|
|
30
|
+
| [Crush](crush.md) | `http://127.0.0.1:8402/v1` | **con**, sin barra final | `~/.config/crush/crushrc` | — (config de usuario) | [V] |
|
|
31
|
+
| [Droid](droid.md) | `http://127.0.0.1:8402` | sin **[I]** | `~/.factory/settings.json` | — (config de usuario) | validar en mano |
|
|
32
|
+
| [Cline](cline.md) | `http://127.0.0.1:8402/v1` | **con** | UI | — | [V] |
|
|
33
|
+
| [Kilo Code](kilo.md) | `http://127.0.0.1:8402/v1` | con **[I]** | UI | — | validar en mano |
|
|
34
|
+
|
|
35
|
+
Reglas de la columna «Base URL»: **nunca barra final**, **nunca redirecciones**
|
|
36
|
+
(un 3xx en el base URL es fallo por diseño del cliente, y el gateway lo reenvía
|
|
37
|
+
en vez de seguirlo). Y nunca copies el URL de un terminal a otro: cada SDK añade
|
|
38
|
+
su propio tramo de ruta y el error es silencioso (§ «Comprobar que mide»).
|
|
39
|
+
|
|
40
|
+
Claude Code, Codex, Goose y Qwen Code tienen **envoltorio propio** (`bvsala
|
|
41
|
+
claude|codex|goose|qwen`): arrancan un gateway en puerto efímero, lanzan el
|
|
42
|
+
terminal apuntándole y lo apagan al salir. Cero configuración persistente. La
|
|
43
|
+
receta manual de cada guía hace exactamente lo mismo. Para el resto,
|
|
44
|
+
`bvsala doctor` imprime el bloque exacto de cada terminal instalado.
|
|
45
|
+
|
|
46
|
+
## Lo común a todas las guías
|
|
47
|
+
|
|
48
|
+
### 1 · Instalar y arrancar el gateway
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm i -g @bivelio/savings-layer # trae el binario `bvsala`
|
|
52
|
+
export BIVELIO_SERVICE_KEY=bvk_… # clave de servicio, desde tu panel. Sin ella: reenvía, NO mide
|
|
53
|
+
bvsala gateway # escucha en http://127.0.0.1:8402
|
|
54
|
+
bvsala gateway --puerto 8500 # otro puerto si el 8402 está ocupado
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Al arrancar imprime dos líneas; la segunda te dice si estás midiendo:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
[bvsala gateway] v0.4.4 escuchando en http://127.0.0.1:8402
|
|
61
|
+
[bvsala gateway] medición: ACTIVA (clave de servicio presente)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`medición: APAGADA (falta BIVELIO_SERVICE_KEY)` = el terminal funciona igual,
|
|
65
|
+
pero nada llega a tu panel.
|
|
66
|
+
|
|
67
|
+
Un gateway habla con **un** upstream (`BVSALA_GATEWAY_UPSTREAM`, por defecto
|
|
68
|
+
`https://api.anthropic.com`). Los terminales del dialecto Anthropic comparten
|
|
69
|
+
instancia; Codex necesita la suya contra OpenAI — `bvsala codex` la arranca
|
|
70
|
+
sola con el upstream correcto según tu modo de auth (ver su guía).
|
|
71
|
+
|
|
72
|
+
### 2 · Comprobar que mide: el log del gateway
|
|
73
|
+
|
|
74
|
+
Por cada llamada de inferencia servida y medida, el gateway escribe una línea
|
|
75
|
+
con el coste:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
[gateway] POST /v1/messages · <modelo> · $0.001234 (in 120, cache↑0 cache↓46792, out 8)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Si ves esa línea con `$`, el terminal está enrutado y medido. Lo que significan
|
|
82
|
+
las otras líneas:
|
|
83
|
+
|
|
84
|
+
| Línea en el log | Qué pasa | Arreglo |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `[gateway] POST /v1/v1/messages → 404` | el base URL lleva `/v1` **de más** para ese terminal | quita el `/v1` |
|
|
87
|
+
| `[gateway] POST /messages → 404` | al base URL le **falta** `/v1` (el SDK solo añade `/messages`) | añade el `/v1` |
|
|
88
|
+
| `[bvsala gateway] el modelo "…" no está en el catálogo de precios; su uso se reenvía pero no se mide.` | modelo fuera de catálogo (a propósito: presente-y-mal es peor que ausente) | nada; se reenvía sin medir |
|
|
89
|
+
| nada de nada | el terminal no está apuntando al gateway (config no leída, env pisado por config, otro puerto) | revisa la trampa de su guía |
|
|
90
|
+
|
|
91
|
+
Claude Code manda además un `HEAD /api/hello` antes de la primera llamada: es
|
|
92
|
+
la sonda de vida, no una inferencia.
|
|
93
|
+
|
|
94
|
+
`bvsala doctor [--puerto N]`: sondea el PATH y la existencia de las configs de
|
|
95
|
+
usuario (nunca lee su contenido) e imprime el bloque exacto por terminal con el
|
|
96
|
+
puerto indicado. Solo imprime: no escribe ninguna configuración.
|
|
97
|
+
|
|
98
|
+
### 3 · Windows
|
|
99
|
+
|
|
100
|
+
`npm i -g @bivelio/savings-layer` y `bvsala claude` son idénticos en los tres
|
|
101
|
+
sistemas. Lo que cambia es cómo se fija la clave en la sesión — el `export` de
|
|
102
|
+
estas guías es POSIX; en PowerShell:
|
|
103
|
+
|
|
104
|
+
```powershell
|
|
105
|
+
$env:BIVELIO_SERVICE_KEY = "bvk_…"
|
|
106
|
+
bvsala claude
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
(En `cmd`: `set BIVELIO_SERVICE_KEY=bvk_…`.) El prefijo `VAR=valor comando` de
|
|
110
|
+
las recetas manuales también es POSIX: en Windows, fija la variable primero y
|
|
111
|
+
lanza después.
|
|
112
|
+
|
|
113
|
+
Y una condición dura: el terminal debe estar instalado con su **instalador
|
|
114
|
+
nativo** (un `claude.exe` / `codex.exe` en el PATH — Claude Code, Codex, Goose
|
|
115
|
+
y Qwen Code lo tienen [V]). El shim `.cmd` que deja `npm i -g <terminal>` no
|
|
116
|
+
puede lanzarse de forma segura con argumentos (CVE-2024-27980) y `bvsala` se
|
|
117
|
+
niega con una guía en vez de fallar con un ENOENT mudo.
|
|
118
|
+
|
|
119
|
+
### 4 · Regla BYOK, sin excepción
|
|
120
|
+
|
|
121
|
+
Todas las recetas del anillo 2 (Goose, Qwen Code, opencode, Crush, Droid, Cline,
|
|
122
|
+
Kilo) son **API key exclusivamente**. Anthropic prohíbe usar el OAuth de Claude
|
|
123
|
+
Pro/Max en harnesses de terceros (opencode lo documenta; Crush retiró su OAuth en
|
|
124
|
+
ene-2026). El gateway reenviaría un Bearer de suscripción sin inmutarse — y eso
|
|
125
|
+
no se promociona ni se documenta como receta.
|
|
126
|
+
|
|
127
|
+
### 5 · Wording honesto
|
|
128
|
+
|
|
129
|
+
«Anthropic doesn't endorse, maintain, or audit third-party gateway products»
|
|
130
|
+
[V]. El modo BV-SALA es **configuración soportada del binario sin modificar**:
|
|
131
|
+
di eso, nunca «aprobado por Anthropic». El contrato del gateway es una lista
|
|
132
|
+
abierta de cabeceras y campos que crece con cada release del terminal; el
|
|
133
|
+
passthrough byte a byte es lo que lo mantiene vivo sin tocar nada.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Claude Code a través del gateway
|
|
2
|
+
|
|
3
|
+
**Fuente:** `docs/PASARELA-S3-TERMINALES.md` §1 (conformidad con el protocolo
|
|
4
|
+
oficial de gateway de Claude Code, leído íntegro el 2026-08-30). Es el terminal
|
|
5
|
+
de referencia: 10 de 12 requisitos del protocolo los cumple el passthrough por
|
|
6
|
+
construcción, con test.
|
|
7
|
+
|
|
8
|
+
## Qué necesitas
|
|
9
|
+
|
|
10
|
+
- `claude` en el PATH.
|
|
11
|
+
- **Una API key de Anthropic** (`ANTHROPIC_API_KEY`, o `ANTHROPIC_AUTH_TOKEN` /
|
|
12
|
+
`apiKeyHelper`): es el segmento que BV-SALA mide y factura. Con login de
|
|
13
|
+
claude.ai el enrutado también funciona — la doc oficial lo sanciona: el login
|
|
14
|
+
sigue siendo la credencial activa y «its usage limits and billing apply» [V] —
|
|
15
|
+
pero ahí no hay factura por token que recortar.
|
|
16
|
+
- `bvsala` instalado y `BIVELIO_SERVICE_KEY` exportada (ver [README](README.md)).
|
|
17
|
+
|
|
18
|
+
## Envoltorio: `bvsala claude` (recomendado)
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export BIVELIO_SERVICE_KEY=bvk_…
|
|
22
|
+
bvsala claude # cualquier argumento pasa tal cual: bvsala claude -p "hola"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Arranca el gateway en un **puerto efímero**, lanza `claude` con
|
|
26
|
+
`ANTHROPIC_BASE_URL=http://127.0.0.1:<puerto>` en su entorno, hereda tu
|
|
27
|
+
terminal entera (el TUI funciona tal cual) y apaga el gateway cuando `claude`
|
|
28
|
+
sale. No escribe ninguna configuración. No toca credenciales: el reenvío de
|
|
29
|
+
`Authorization` / `x-api-key` es íntegro.
|
|
30
|
+
|
|
31
|
+
## Manual: `ANTHROPIC_BASE_URL`
|
|
32
|
+
|
|
33
|
+
Si prefieres un gateway compartido en un puerto fijo (por ejemplo para varios
|
|
34
|
+
terminales a la vez):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bvsala gateway # ventana 1: http://127.0.0.1:8402
|
|
38
|
+
ANTHROPIC_BASE_URL=http://127.0.0.1:8402 claude # ventana 2 — SIN /v1
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Claude Code añade `/v1/messages` por su cuenta. Sin barra final.
|
|
42
|
+
|
|
43
|
+
## Comprobar que funciona
|
|
44
|
+
|
|
45
|
+
En el log del gateway, en este orden:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
[gateway] HEAD /api/hello → 200
|
|
49
|
+
[gateway] POST /v1/messages · <modelo> · $0.001234 (in 120, cache↑0 (1h 49268) cache↓43430, out 8)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
El `HEAD /api/hello` es la sonda de vida que Claude Code manda antes de la
|
|
53
|
+
primera llamada. La línea con `$` es la inferencia medida. La cifra `cache↑ …
|
|
54
|
+
(1h …)` es normal: Claude Code escribe caché a 1 h, y es más de la mitad de la
|
|
55
|
+
entrada en sesiones reales.
|
|
56
|
+
|
|
57
|
+
## Model discovery (opcional, solo con API key)
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 bvsala claude
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Con la bandera, Claude Code pide `GET /v1/models?limit=1000` al gateway
|
|
64
|
+
(timeout 3 s, credencial en `Authorization` **y** `x-api-key`) y filtra ids que
|
|
65
|
+
contengan «claude»/«anthropic» [V]. El passthrough lo sirve por construcción
|
|
66
|
+
(api.anthropic.com responde `{data:[{id,display_name}]}`) y en `/model` debería
|
|
67
|
+
aparecer «From gateway».
|
|
68
|
+
|
|
69
|
+
Honestidad sobre el estado: el passthrough de `GET /v1/models` **tiene test**
|
|
70
|
+
(pasa íntegro y no factura); la validación en mano con API key está pendiente
|
|
71
|
+
(S3 §1.2). Y las únicas fuentes de credencial
|
|
72
|
+
del discovery son `ANTHROPIC_AUTH_TOKEN`/`apiKeyHelper` y `ANTHROPIC_API_KEY`: un
|
|
73
|
+
login claude.ai **no** es fuente, así que en suscripción pura el cliente escribe
|
|
74
|
+
`[gatewayDiscovery] skipped` y ni pregunta.
|
|
75
|
+
|
|
76
|
+
## La trampa típica
|
|
77
|
+
|
|
78
|
+
1. **`/v1` en el base URL.** `ANTHROPIC_BASE_URL=http://127.0.0.1:8402/v1` da
|
|
79
|
+
`[gateway] POST /v1/v1/messages → 404` y Claude Code reporta un error
|
|
80
|
+
genérico. Sin `/v1`.
|
|
81
|
+
2. **Watchdog de 300 s.** En conexiones por `ANTHROPIC_BASE_URL` Claude Code
|
|
82
|
+
corta un stream sin bytes durante 300 s (único valor que documenta hoy la doc
|
|
83
|
+
oficial). En el camino normal el gateway no bufferiza y reenvía los pings
|
|
84
|
+
como bytes, así que no salta. Solo bajo `BVSALA_GATEWAY_COALESCE=1` (apagada
|
|
85
|
+
por defecto) una petición gemela espera a que termine la primaria, y una
|
|
86
|
+
primaria de más de 300 s la mataría — está identificado en S3 §1.1 y no se
|
|
87
|
+
enciende por defecto hasta arreglarlo.
|
|
88
|
+
3. **Redirecciones.** Cualquier 3xx en el base URL es fallo de discovery por
|
|
89
|
+
diseño del cliente; el gateway reenvía el 3xx en vez de seguirlo con tu
|
|
90
|
+
credencial (`redirect: "manual"`, verificado).
|
|
91
|
+
|
|
92
|
+
## Lo que NO pasa por el gateway (aviso honesto)
|
|
93
|
+
|
|
94
|
+
La promesa de medición es «todo el tráfico de **inferencia**», no «todo el
|
|
95
|
+
tráfico»: **fast-mode y el safety-check de WebFetch van directos a
|
|
96
|
+
api.anthropic.com por diseño del cliente** [V]. Ese gasto no aparece en tu panel
|
|
97
|
+
y no es computable desde el gateway. El contrato es una lista abierta que crece
|
|
98
|
+
con cada release de Claude Code; BV-SALA repite el smoke test de `bvsala claude`
|
|
99
|
+
contra cada versión nueva.
|
|
100
|
+
|
|
101
|
+
Las cabeceras `x-claude-code-session-id` / `agent-id` / `parent-agent-id`
|
|
102
|
+
llegan en el tráfico real y hoy se reenvían sin consumirlas: el coste por sesión
|
|
103
|
+
y por subagente está previsto en S3 §1.3, todavía no en el panel.
|
|
104
|
+
|
|
105
|
+
## Nota BYOK y wording
|
|
106
|
+
|
|
107
|
+
El modo BV-SALA es **configuración soportada del binario sin modificar**, nunca
|
|
108
|
+
«aprobado por Anthropic» («Anthropic doesn't endorse, maintain, or audit
|
|
109
|
+
third-party gateway products» [V]). Lo que BV-SALA mide y factura es el uso por
|
|
110
|
+
API key; el gateway no persiste ninguna credencial, sea cual sea.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Cline a través del gateway
|
|
2
|
+
|
|
3
|
+
**Fuente:** `docs/PASARELA-S3-TERMINALES.md` §3 [V]. Cline configura el
|
|
4
|
+
proveedor por **UI** (extensión de VS Code), así que aquí no hay archivo ni
|
|
5
|
+
envoltorio: solo pasos y la forma exacta del URL, que es **con `/v1`**.
|
|
6
|
+
|
|
7
|
+
Por qué con `/v1` (refutación verificada): Cline `main` ya usa
|
|
8
|
+
`@ai-sdk/anthropic`, no el SDK TS oficial; su `normalizeBaseURL` solo añade
|
|
9
|
+
`/v1` cuando el URL es exactamente `https://api.anthropic.com`, y `builtins.ts`
|
|
10
|
+
fija `https://api.anthropic.com/v1` [V]. Con un URL custom, lo que pongas es lo
|
|
11
|
+
que se usa.
|
|
12
|
+
|
|
13
|
+
## Qué necesitas
|
|
14
|
+
|
|
15
|
+
- Cline instalado en VS Code.
|
|
16
|
+
- Tu **API key** de Anthropic.
|
|
17
|
+
- `bvsala gateway` corriendo en `http://127.0.0.1:8402` (ver [README](README.md)).
|
|
18
|
+
|
|
19
|
+
## Pasos en la UI
|
|
20
|
+
|
|
21
|
+
1. Abre los ajustes de Cline → **API Provider**: elige **Anthropic**.
|
|
22
|
+
2. Pega tu **Anthropic API Key**.
|
|
23
|
+
3. Marca **«Use custom base URL»** y escribe exactamente:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
http://127.0.0.1:8402/v1
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Con `/v1`, sin barra final.**
|
|
30
|
+
4. Elige el modelo y guarda.
|
|
31
|
+
|
|
32
|
+
## Comprobar que funciona
|
|
33
|
+
|
|
34
|
+
Manda un mensaje trivial desde Cline y mira la ventana del gateway:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
[gateway] POST /v1/messages · <modelo> · $0.001234 (in 120, cache↑0 cache↓0, out 8)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## La trampa típica
|
|
41
|
+
|
|
42
|
+
- **Sin `/v1`** → Cline pide `…/messages` → `[gateway] POST /messages → 404` y
|
|
43
|
+
la UI muestra un error genérico de API. Añade el `/v1`.
|
|
44
|
+
- **Barra final** → no.
|
|
45
|
+
- **Gateway apagado**: la UI recuerda el base URL custom; cuando no tengas
|
|
46
|
+
`bvsala gateway` corriendo, Cline fallará hasta que lo arranques o desmarques
|
|
47
|
+
la casilla.
|
|
48
|
+
|
|
49
|
+
## Nota BYOK
|
|
50
|
+
|
|
51
|
+
**API key exclusivamente.** Anthropic prohíbe usar el OAuth de Claude Pro/Max en
|
|
52
|
+
harnesses de terceros (Cline estuvo en la primera oleada del bloqueo de
|
|
53
|
+
ene-2026). Esta receta es la del proveedor Anthropic con API key; nada más.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# Codex CLI a través del gateway
|
|
2
|
+
|
|
3
|
+
**Fuente:** `docs/PASARELA-S3-TERMINALES.md` §2 (código de `openai/codex`,
|
|
4
|
+
`openai-node` y docs de OpenAI, 2026-08-30). **Estado: envoltorio en el
|
|
5
|
+
paquete y validado en mano el 30-08** (sesión real de Codex v0.134 con auth
|
|
6
|
+
ChatGPT: 426 → fallback HTTP, stream íntegro, registro `measured` en la
|
|
7
|
+
ingesta). Lee «Qué mide» y las validaciones pendientes antes de anunciar
|
|
8
|
+
cifras.
|
|
9
|
+
|
|
10
|
+
## Qué necesitas
|
|
11
|
+
|
|
12
|
+
- `codex` en el PATH.
|
|
13
|
+
- Login en Codex: **API key de OpenAI** (el caso que se monetiza) o
|
|
14
|
+
suscripción ChatGPT (se enruta y se mide, pero **no se monetiza**: no hay
|
|
15
|
+
factura por tokens; ver más abajo).
|
|
16
|
+
- `bvsala` instalado y `BIVELIO_SERVICE_KEY` exportada (ver [README](README.md)).
|
|
17
|
+
|
|
18
|
+
## Envoltorio: `bvsala codex [args…]`
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export BIVELIO_SERVICE_KEY=bvk_…
|
|
22
|
+
bvsala codex # cualquier argumento pasa tal cual a codex
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Espejo de `bvsala claude`: gateway efímero con el upstream que toque +
|
|
26
|
+
`spawn("codex", ["-c", 'openai_base_url="http://127.0.0.1:<puerto><prefijo>"', ...resto])`
|
|
27
|
+
(cadena TOML entrecomillada: «values are parsed as TOML», doc oficial). Por qué
|
|
28
|
+
así y no por variable de entorno:
|
|
29
|
+
|
|
30
|
+
- `-c` es flag global de **máxima precedencia** [V]; **no existe** una env
|
|
31
|
+
`OPENAI_BASE_URL` en Codex (cero referencias en su código de config) [V].
|
|
32
|
+
- Sin escribir config, el ajuste muere con el proceso: nada queda apuntando a un
|
|
33
|
+
gateway que ya no corre.
|
|
34
|
+
- **El prefijo depende del modo de auth** [V `AuthDotJson`, `to_api_provider`]:
|
|
35
|
+
`ApiKey` → upstream `https://api.openai.com`, prefijo `/v1`; toda la familia
|
|
36
|
+
ChatGPT → upstream `https://chatgpt.com`, prefijo `/backend-api/codex`. El
|
|
37
|
+
envoltorio lee `auth_mode` de `~/.codex/auth.json` **por existencia/valor del
|
|
38
|
+
enum, jamás imprime tokens**; `BVSALA_CODEX_AUTH=api-key|chatgpt` lo fuerza.
|
|
39
|
+
|
|
40
|
+
## Manual (API key): `-c openai_base_url=…`
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
BVSALA_GATEWAY_UPSTREAM=https://api.openai.com bvsala gateway # ventana 1: http://127.0.0.1:8402
|
|
44
|
+
codex -c openai_base_url=http://127.0.0.1:8402/v1 # ventana 2 — CON /v1, sin barra final
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Codex añade `/responses` (`POST {base_url}/responses`, siempre `stream: true`)
|
|
48
|
+
[V]. Si en el 8402 ya tienes el gateway de Anthropic para otros terminales,
|
|
49
|
+
arranca este en otro puerto (`--puerto 8403`) y apunta ahí: un gateway habla con
|
|
50
|
+
un solo upstream.
|
|
51
|
+
|
|
52
|
+
No lo escribas en `~/.codex/config.toml`: ahí se quedaría fijo y Codex fallaría
|
|
53
|
+
cada vez que el gateway no esté corriendo. El `-c` por sesión es la forma.
|
|
54
|
+
|
|
55
|
+
## Comprobar que funciona
|
|
56
|
+
|
|
57
|
+
En el log del gateway verás, en este orden, la sesión real (30-08):
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
[gateway] GET /backend-api/codex/models → 200
|
|
61
|
+
[gateway] GET /backend-api/codex/responses → 426 (WebSocket no soportado; el cliente cae a HTTP)
|
|
62
|
+
[gateway] POST /backend-api/codex/responses → el cliente cerró tras el usage; se mide igual
|
|
63
|
+
[gateway] POST /backend-api/codex/responses · gpt-5.5 · $0.210766 (in 41880, cache↑0 cache↓2432, out 5)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
(Con API key la ruta es `/v1/responses`.) La línea con `$` es la medición.
|
|
67
|
+
|
|
68
|
+
**Qué mide:** el `usage` de `event: response.completed` con la fórmula oficial
|
|
69
|
+
de OpenAI (`entrada = input − cached − writes`; `cached` a la tarifa cacheada
|
|
70
|
+
del catálogo; `writes` a 1,25×, la prima oficial de gpt-5.6+) [V]. Tres cosas
|
|
71
|
+
que el tráfico real enseñó y el gateway ya contempla:
|
|
72
|
+
|
|
73
|
+
- Codex **cierra la conexión nada más recibir `response.completed`**; el
|
|
74
|
+
gateway mide igual con el usage ya observado («se mide igual» en el log).
|
|
75
|
+
- El backend de ChatGPT sirve el SSE **sin cabecera `content-type`**; en las
|
|
76
|
+
rutas `…/responses` el gateway asume SSE si falta.
|
|
77
|
+
- Codex manda el cuerpo de la petición **comprimido con zstd**; el gateway lo
|
|
78
|
+
reenvía intacto y lee el `model` a través del `content-encoding` (Node ≥ 22.15
|
|
79
|
+
para zstd; en un Node sin zstd la petición pasa y no se mide).
|
|
80
|
+
|
|
81
|
+
## La trampa típica
|
|
82
|
+
|
|
83
|
+
1. **El prefijo.** Con API key es `/v1` **exacto**. Con la familia ChatGPT el
|
|
84
|
+
base_url tiene que terminar **exactamente** en `/backend-api/codex` o
|
|
85
|
+
`supports_codex_backend_routes()` se apaga [V]. Mezclarlos = 404 silencioso.
|
|
86
|
+
2. **Una línea `failed to connect to websocket: HTTP error: 426`** al arrancar
|
|
87
|
+
es normal: Codex intenta WebSocket primero (`supports_websockets: true` está
|
|
88
|
+
hardcodeado y no se apaga por config [V]); el gateway responde `426 Upgrade
|
|
89
|
+
Required` y el fallback a HTTP es inmediato y pegajoso por sesión [V test
|
|
90
|
+
`websocket_fallback.rs`]. Sin «Reconnecting…».
|
|
91
|
+
3. **`failed to refresh available models: unknown variant …`** es de tu versión
|
|
92
|
+
de Codex frente a su backend: aparece igual **sin** gateway (comprobado).
|
|
93
|
+
4. **Barra final.** `…/v1/` no.
|
|
94
|
+
|
|
95
|
+
## Modo suscripción ChatGPT — validado, medido, NO monetizado
|
|
96
|
+
|
|
97
|
+
Validado en mano el 30-08 (prefijo `/backend-api/codex`, upstream
|
|
98
|
+
`https://chatgpt.com`): 200, stream íntegro, `usage` presente en
|
|
99
|
+
`response.completed` y registro `measured` en la ingesta. **Pero un suscriptor
|
|
100
|
+
no paga por tokens**: la cifra que ve es el equivalente a precio de lista por
|
|
101
|
+
API key, no una factura; y como la comisión de BV-SALA solo toca ahorro medido
|
|
102
|
+
sobre gasto real, este tráfico **no se monetiza** — igual que el OAuth de
|
|
103
|
+
suscripción en Claude Code (`docs/PASARELA-F0.md`: «el caso que NO
|
|
104
|
+
facturamos»). OpenAI ni bendice ni prohíbe el proxy de suscripción (silencio
|
|
105
|
+
del staff, a diferencia de Anthropic, que lo documenta). Postura idéntica a
|
|
106
|
+
Claude Code: cero persistencia de `Authorization` / `ChatGPT-Account-ID`,
|
|
107
|
+
binario sin modificar, y no se promociona como modo de ahorro.
|
|
108
|
+
|
|
109
|
+
**Laguna conocida:** el registro que emite el gateway para un suscriptor es hoy
|
|
110
|
+
**idéntico** al de una API key (`basis: measured`, coste a precio de lista) —
|
|
111
|
+
el panel no puede distinguirlos. Mientras la comisión sea 0 no hay efecto; antes
|
|
112
|
+
de que S4 calcule comisión sobre ahorro medido, S2 etiquetará la auth de
|
|
113
|
+
suscripción en el wire para que ese tráfico jamás entre en la base. Está en el
|
|
114
|
+
plan (`docs/PLAN-PASARELA-API-KEY.md`, S3).
|
|
115
|
+
|
|
116
|
+
## Validaciones en mano
|
|
117
|
+
|
|
118
|
+
- ✅ (b) Suscripción ChatGPT: 426 → fallback, 200, stream íntegro, `usage`
|
|
119
|
+
presente, registro `measured` (30-08).
|
|
120
|
+
- ⏳ (a) Sesión con **API key** (ruta `/v1/responses` contra api.openai.com):
|
|
121
|
+
misma mecánica, pendiente de una cuenta con API key.
|
|
122
|
+
- ⏳ (c) Plausibilidad `cached + writes ≤ input` en un prompt repetido > 1.024
|
|
123
|
+
tokens (OpenAI reconoció contadores mal en el arranque de 5.6). El gateway
|
|
124
|
+
ya recorta al lado que infra-mide si no cuadra.
|
|
125
|
+
- ⏳ Contrastar la cifra del gateway con la consola del proveedor en una sesión
|
|
126
|
+
larga (Codex imprime su propio «tokens used»; la fuente de BV-SALA es el
|
|
127
|
+
`usage` del proveedor, no ese contador).
|
|
128
|
+
|
|
129
|
+
## Nota BYOK
|
|
130
|
+
|
|
131
|
+
La receta documentada es **API key exclusivamente**. El gateway no persiste
|
|
132
|
+
ninguna credencial; lo que factura BV-SALA es el uso por API key.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Crush a través del gateway
|
|
2
|
+
|
|
3
|
+
**Fuente:** `docs/PASARELA-S3-TERMINALES.md` §3 [V]. Crush (Charm) configura
|
|
4
|
+
proveedores por **`~/.config/crush/crushrc`** (un script Bash); `crush.json`
|
|
5
|
+
está **deprecado** (verificado 2026-08-30). Su SDK Go resuelve rutas de forma
|
|
6
|
+
relativa, y eso dicta la forma del URL: **con `/v1`, sin barra final**.
|
|
7
|
+
|
|
8
|
+
## Qué necesitas
|
|
9
|
+
|
|
10
|
+
- `crush` instalado.
|
|
11
|
+
- `ANTHROPIC_API_KEY` exportada (Crush retiró su OAuth de Claude en ene-2026:
|
|
12
|
+
solo API key).
|
|
13
|
+
- `bvsala` instalado y `BIVELIO_SERVICE_KEY` exportada (ver [README](README.md)).
|
|
14
|
+
|
|
15
|
+
## Config de usuario: `~/.config/crush/crushrc`
|
|
16
|
+
|
|
17
|
+
Añade esta línea:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
provider add bvsala --type anthropic --base-url http://127.0.0.1:8402/v1 --api-key "$ANTHROPIC_API_KEY"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Y elige el proveedor `bvsala` en Crush. `crush.json` no: deprecado.
|
|
24
|
+
|
|
25
|
+
No hay envoltorio `bvsala crush`: el enrutado es config de usuario. El
|
|
26
|
+
`bvsala doctor --write crush` previsto en S3 hará un append comentado de esa
|
|
27
|
+
línea en tu `crushrc` (con copia `.bak`).
|
|
28
|
+
|
|
29
|
+
## Arrancar y comprobar
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bvsala gateway # http://127.0.0.1:8402
|
|
33
|
+
crush
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
[gateway] POST /v1/messages · <modelo> · $0.001234 (in 120, cache↑0 cache↓0, out 8)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## La trampa típica
|
|
41
|
+
|
|
42
|
+
- **Barra final.** `--base-url http://127.0.0.1:8402/v1/` → el SDK Go resuelve
|
|
43
|
+
relativo y pide `/v1/v1/messages` → `[gateway] POST /v1/v1/messages → 404` [V].
|
|
44
|
+
Sin barra.
|
|
45
|
+
- **Sin `/v1`** → `[gateway] POST /messages → 404`.
|
|
46
|
+
- **`crush.json`** → deprecado; si tienes uno viejo con otro proveedor, puede
|
|
47
|
+
seguir mandando. Usa `crushrc`.
|
|
48
|
+
|
|
49
|
+
## Nota BYOK
|
|
50
|
+
|
|
51
|
+
**API key exclusivamente.** Crush ya retiró su OAuth de Claude; el gateway
|
|
52
|
+
reenviaría cualquier credencial sin inmutarse, pero la receta es esta.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Droid (Factory) a través del gateway
|
|
2
|
+
|
|
3
|
+
**Fuente:** `docs/PASARELA-S3-TERMINALES.md` §3. Droid es **cerrado**; el
|
|
4
|
+
mecanismo de enrutado está verificado [V] (`~/.factory/settings.json` →
|
|
5
|
+
`customModels[]` con `provider: "anthropic"`), pero **la forma del base URL es
|
|
6
|
+
inferida [I]: hay que validarla en mano contra el log del gateway.** Esta guía
|
|
7
|
+
lo dice tal cual; no hay validación hecha.
|
|
8
|
+
|
|
9
|
+
## Qué necesitas
|
|
10
|
+
|
|
11
|
+
- `droid` instalado, en modo BYOK con tu **API key** de Anthropic.
|
|
12
|
+
- `bvsala` instalado y `BIVELIO_SERVICE_KEY` exportada (ver [README](README.md)).
|
|
13
|
+
|
|
14
|
+
## Config de usuario: `~/.factory/settings.json`
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"customModels": [
|
|
19
|
+
{
|
|
20
|
+
"provider": "anthropic",
|
|
21
|
+
"baseUrl": "http://127.0.0.1:8402",
|
|
22
|
+
"apiKey": "<tu ANTHROPIC_API_KEY>"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Las claves verificadas [V] son `customModels[]{provider:"anthropic", baseUrl,
|
|
29
|
+
apiKey}`. La forma **sin `/v1`** es inferida [I]. Conserva el resto de tu
|
|
30
|
+
`settings.json` (el `doctor --write droid` previsto en S3 hará el merge JSON
|
|
31
|
+
conservador con copia `.bak`).
|
|
32
|
+
|
|
33
|
+
## Validar en mano (obligatorio antes de fiarte)
|
|
34
|
+
|
|
35
|
+
1. `bvsala gateway` en una ventana.
|
|
36
|
+
2. Lanza `droid` con ese modelo custom y haz una pregunta trivial.
|
|
37
|
+
3. Mira el log del gateway:
|
|
38
|
+
- `[gateway] POST /v1/messages · <modelo> · $…` → la forma sin `/v1` es la
|
|
39
|
+
buena. Confírmalo en S3 (pasar el [I] a [V]).
|
|
40
|
+
- `[gateway] POST /messages → 404` → Droid solo añade `/messages`: cambia el
|
|
41
|
+
`baseUrl` a `http://127.0.0.1:8402/v1` y repite.
|
|
42
|
+
- nada en el log → Droid no está leyendo el modelo custom; revisa que sea el
|
|
43
|
+
modelo seleccionado.
|
|
44
|
+
|
|
45
|
+
## La trampa típica
|
|
46
|
+
|
|
47
|
+
- **Adivinar el `/v1`**: por eso el paso de validación. Un URL mal formado
|
|
48
|
+
falla en silencio (404 del upstream sin medir).
|
|
49
|
+
- **La API key va en el JSON**: es config de usuario en tu máquina; no la
|
|
50
|
+
copies a ningún repo ni la pegues en un ticket.
|
|
51
|
+
|
|
52
|
+
## Nota BYOK
|
|
53
|
+
|
|
54
|
+
**API key exclusivamente.** Droid es BYOK; Anthropic prohíbe el OAuth de Claude
|
|
55
|
+
Pro/Max en harnesses de terceros, y esta receta no lo contempla.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Goose a través del gateway
|
|
2
|
+
|
|
3
|
+
**Fuente:** `docs/PASARELA-S3-TERMINALES.md` §3 [V]. Goose habla dialecto
|
|
4
|
+
Anthropic puro y autentica con `x-api-key` + `anthropic-version`, que el
|
|
5
|
+
reenvío pasa intactos: **nada que cambiar en el gateway**, todo está en la
|
|
6
|
+
forma del URL.
|
|
7
|
+
|
|
8
|
+
## Qué necesitas
|
|
9
|
+
|
|
10
|
+
- `goose` en el PATH, con el proveedor Anthropic elegido.
|
|
11
|
+
- `ANTHROPIC_API_KEY` exportada (Goose es solo BYOK).
|
|
12
|
+
- `bvsala` instalado y `BIVELIO_SERVICE_KEY` exportada (ver [README](README.md)).
|
|
13
|
+
|
|
14
|
+
## Envoltorio: `bvsala goose`
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
export BIVELIO_SERVICE_KEY=bvk_…
|
|
18
|
+
bvsala goose # espejo exacto de `bvsala claude`
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Espejo exacto de `bvsala claude`: gateway efímero, `goose` lanzado con
|
|
22
|
+
`ANTHROPIC_HOST=http://127.0.0.1:<puerto>`, todo muere al salir. La tabla vive
|
|
23
|
+
en `gateway/envoltorios.ts`. La receta manual de abajo es literalmente lo mismo.
|
|
24
|
+
|
|
25
|
+
## Manual: `ANTHROPIC_HOST`
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bvsala gateway # ventana 1: http://127.0.0.1:8402
|
|
29
|
+
ANTHROPIC_HOST=http://127.0.0.1:8402 goose # ventana 2 — SIN /v1
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`ANTHROPIC_HOST` **pisa** lo que haya en `config.yaml` [V], así que no hace
|
|
33
|
+
falta tocar ningún archivo. Sin `/v1`, sin barra final: Goose añade
|
|
34
|
+
`/v1/messages` por su cuenta.
|
|
35
|
+
|
|
36
|
+
## Comprobar que funciona
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
[gateway] POST /v1/messages · <modelo> · $0.001234 (in 120, cache↑0 cache↓0, out 8)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## La trampa típica
|
|
43
|
+
|
|
44
|
+
- **`/v1` en `ANTHROPIC_HOST`** → `[gateway] POST /v1/v1/messages → 404`. Quítalo.
|
|
45
|
+
- **Escribir el host en `config.yaml`** en vez de la variable: funciona, pero
|
|
46
|
+
se queda fijo y Goose falla cada vez que el gateway no esté corriendo. La
|
|
47
|
+
variable de entorno por sesión es la forma; la env pisa al archivo de todos
|
|
48
|
+
modos.
|
|
49
|
+
|
|
50
|
+
## Nota BYOK
|
|
51
|
+
|
|
52
|
+
**API key exclusivamente.** Anthropic prohíbe usar el OAuth de Claude Pro/Max
|
|
53
|
+
en harnesses de terceros; Goose es BYOK y esta receta también. El gateway
|
|
54
|
+
reenviaría cualquier credencial sin inmutarse — eso no es una receta.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Kilo Code a través del gateway
|
|
2
|
+
|
|
3
|
+
**Fuente:** `docs/PASARELA-S3-TERMINALES.md` §3. Kilo Code configura por **UI**
|
|
4
|
+
(proveedor custom con Provider API «Anthropic Messages»; la config queda en
|
|
5
|
+
`kilo.json`, detectable) [V]. **La forma del base URL es inferida [I] por
|
|
6
|
+
linaje Cline: probar con `/v1` primero y validar en mano contra el log del
|
|
7
|
+
gateway.** No hay validación hecha; esta guía lo dice tal cual.
|
|
8
|
+
|
|
9
|
+
## Qué necesitas
|
|
10
|
+
|
|
11
|
+
- Kilo Code instalado en VS Code.
|
|
12
|
+
- Tu **API key** de Anthropic.
|
|
13
|
+
- `bvsala gateway` corriendo en `http://127.0.0.1:8402` (ver [README](README.md)).
|
|
14
|
+
|
|
15
|
+
## Pasos en la UI
|
|
16
|
+
|
|
17
|
+
1. Ajustes de Kilo Code → añade un **proveedor custom**.
|
|
18
|
+
2. **Provider API**: «Anthropic Messages».
|
|
19
|
+
3. Pega tu **API key** de Anthropic.
|
|
20
|
+
4. **Base URL** — prueba primero:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
http://127.0.0.1:8402/v1
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Sin barra final.**
|
|
27
|
+
5. Elige el modelo y guarda.
|
|
28
|
+
|
|
29
|
+
## Validar en mano (obligatorio antes de fiarte)
|
|
30
|
+
|
|
31
|
+
Manda un mensaje trivial y mira la ventana del gateway:
|
|
32
|
+
|
|
33
|
+
- `[gateway] POST /v1/messages · <modelo> · $…` → **con `/v1`** es la forma
|
|
34
|
+
buena; confírmalo en S3 (pasar el [I] a [V]).
|
|
35
|
+
- `[gateway] POST /v1/v1/messages → 404` → Kilo ya añade `/v1`: quita el `/v1`
|
|
36
|
+
del base URL (`http://127.0.0.1:8402`) y repite.
|
|
37
|
+
- `[gateway] POST /messages → 404` → falta el `/v1`; vuelve a ponerlo.
|
|
38
|
+
- nada en el log → el proveedor custom no es el activo.
|
|
39
|
+
|
|
40
|
+
## La trampa típica
|
|
41
|
+
|
|
42
|
+
- **Adivinar el `/v1`**: de ahí el paso de validación. Un URL mal formado falla
|
|
43
|
+
en silencio (404 del upstream, nada medido, error genérico en la UI).
|
|
44
|
+
- **Gateway apagado**: `kilo.json` recuerda el base URL; sin `bvsala gateway`
|
|
45
|
+
corriendo, Kilo fallará hasta que lo arranques o cambies de proveedor.
|
|
46
|
+
|
|
47
|
+
## Nota BYOK
|
|
48
|
+
|
|
49
|
+
**API key exclusivamente.** Anthropic prohíbe usar el OAuth de Claude Pro/Max en
|
|
50
|
+
harnesses de terceros; esta receta es la del proveedor custom con API key.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# opencode a través del gateway
|
|
2
|
+
|
|
3
|
+
**Fuente:** `docs/PASARELA-S3-TERMINALES.md` §3 [V]. opencode enruta por
|
|
4
|
+
**archivo de configuración**, no por variable de entorno, y su capa
|
|
5
|
+
`packages/llm` añade `/messages` al `baseURL`: por eso aquí el URL lleva `/v1`.
|
|
6
|
+
|
|
7
|
+
## Qué necesitas
|
|
8
|
+
|
|
9
|
+
- `opencode` instalado, proveedor `anthropic` con tu **API key** de Anthropic.
|
|
10
|
+
- `bvsala` instalado y `BIVELIO_SERVICE_KEY` exportada (ver [README](README.md)).
|
|
11
|
+
|
|
12
|
+
## Config de usuario: `~/.config/opencode/opencode.json`
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"provider": {
|
|
17
|
+
"anthropic": {
|
|
18
|
+
"options": {
|
|
19
|
+
"baseURL": "http://127.0.0.1:8402/v1"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Con `/v1`, sin barra final.** Es la clave `provider.anthropic.options.baseURL`
|
|
27
|
+
[V]. El `opencode.json` de proyecto pisa al global: si tienes uno en el repo,
|
|
28
|
+
ahí es donde manda.
|
|
29
|
+
|
|
30
|
+
No hay envoltorio `bvsala opencode`: el enrutado es config de usuario. El
|
|
31
|
+
`bvsala doctor --write opencode` previsto en S3 hará un merge JSON conservador
|
|
32
|
+
de este bloque (con copia `.bak`) en tu config de usuario, nunca en archivos
|
|
33
|
+
del repo.
|
|
34
|
+
|
|
35
|
+
### Alternativa [V]: `baseURL` desde variable de entorno
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{ "provider": { "anthropic": { "options": { "baseURL": "{env:BVSALA_BASE_URL}" } } } }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Escrito una vez, habilita un uso puro-env (`BVSALA_BASE_URL=http://127.0.0.1:8402/v1 opencode`).
|
|
42
|
+
**Trampa:** sin la variable, el `baseURL` queda vacío y opencode no funciona.
|
|
43
|
+
|
|
44
|
+
## Arrancar y comprobar
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bvsala gateway # http://127.0.0.1:8402
|
|
48
|
+
opencode
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
[gateway] POST /v1/messages · <modelo> · $0.001234 (in 120, cache↑0 cache↓0, out 8)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## La trampa típica
|
|
56
|
+
|
|
57
|
+
- **Sin `/v1`** → opencode pide `http://127.0.0.1:8402/messages` → `[gateway] POST /messages → 404`.
|
|
58
|
+
- **Barra final** (`…/v1/`) → no.
|
|
59
|
+
- **Config de proyecto** con otro `baseURL` pisando al global sin que lo sepas.
|
|
60
|
+
- **Gateway apagado con la config escrita**: opencode falla hasta que arranques
|
|
61
|
+
`bvsala gateway` de nuevo. La config es persistente; el gateway, no. (La
|
|
62
|
+
variante `{env:…}` sin exportar la variable también falla, por vacío.)
|
|
63
|
+
|
|
64
|
+
## Nota BYOK
|
|
65
|
+
|
|
66
|
+
**API key exclusivamente.** opencode documenta expresamente que el OAuth de
|
|
67
|
+
Claude Pro/Max no puede usarse en harnesses de terceros; esta receta lo respeta.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Qwen Code a través del gateway
|
|
2
|
+
|
|
3
|
+
**Fuente:** `docs/PASARELA-S3-TERMINALES.md` §3 [V]. Qwen Code con proveedor
|
|
4
|
+
Anthropic habla dialecto Anthropic puro (SDK TS oficial), así que el medidor
|
|
5
|
+
actual lo entiende sin cambios. La familia de modelos **qwen** queda fuera del
|
|
6
|
+
catálogo a propósito (más abajo).
|
|
7
|
+
|
|
8
|
+
## Qué necesitas
|
|
9
|
+
|
|
10
|
+
- `qwen` en el PATH.
|
|
11
|
+
- **Una API key de Anthropic** (`ANTHROPIC_API_KEY`), con Qwen Code configurado
|
|
12
|
+
para usar el proveedor Anthropic.
|
|
13
|
+
- `bvsala` instalado y `BIVELIO_SERVICE_KEY` exportada (ver [README](README.md)).
|
|
14
|
+
|
|
15
|
+
## Config de usuario: `~/.qwen/settings.json`
|
|
16
|
+
|
|
17
|
+
El enrutado necesita dos cosas a la vez [V]: la variable de entorno
|
|
18
|
+
`ANTHROPIC_BASE_URL` **y** que `~/.qwen/settings.json` seleccione el tipo
|
|
19
|
+
Anthropic:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"selectedType": "anthropic"
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Conserva el resto de tu `settings.json`; la clave verificada en S3 es
|
|
28
|
+
`selectedType`. Sin ella, Qwen Code no lee `ANTHROPIC_BASE_URL` y el gateway no
|
|
29
|
+
verá nada.
|
|
30
|
+
|
|
31
|
+
## Envoltorio: `bvsala qwen`
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
export BIVELIO_SERVICE_KEY=bvk_…
|
|
35
|
+
bvsala qwen
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Espejo de `bvsala claude` con `ANTHROPIC_BASE_URL`. El envoltorio **no lee**
|
|
39
|
+
`settings.json`: comprueba tú que `selectedType` sea `anthropic` (`bvsala
|
|
40
|
+
doctor` te recuerda la ruta). La receta manual de abajo es lo mismo.
|
|
41
|
+
|
|
42
|
+
## Manual: `ANTHROPIC_BASE_URL`
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bvsala gateway # ventana 1: http://127.0.0.1:8402
|
|
46
|
+
ANTHROPIC_BASE_URL=http://127.0.0.1:8402 qwen # ventana 2 — SIN /v1
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Sin `/v1`: el SDK TS oficial de Anthropic añade `/v1/messages` [V]. Sin barra
|
|
50
|
+
final.
|
|
51
|
+
|
|
52
|
+
## Comprobar que funciona
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
[gateway] POST /v1/messages · <modelo> · $0.001234 (in 120, cache↑0 cache↓0, out 8)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## La trampa típica
|
|
59
|
+
|
|
60
|
+
1. **`/v1` en el base URL** → `[gateway] POST /v1/v1/messages → 404`.
|
|
61
|
+
2. **`selectedType` no es `anthropic`** → el log del gateway no muestra nada:
|
|
62
|
+
Qwen Code está hablando con su proveedor por defecto, no contigo.
|
|
63
|
+
3. **Modelos qwen (DashScope / ModelStudio)**: están **fuera del catálogo a
|
|
64
|
+
propósito** — `costeMedido → null`, passthrough con un aviso una vez
|
|
65
|
+
(`el modelo "…" no está en el catálogo de precios; su uso se reenvía pero no
|
|
66
|
+
se mide`). Coherente con la política del catálogo: presente-y-mal es peor que
|
|
67
|
+
ausente. Para medirlos harían falta precios **verificados** de
|
|
68
|
+
ModelStudio/DashScope en la fecha, y antes comprobar que DashScope emite SSE
|
|
69
|
+
con `\n\n` — el observador parte bloques solo por `\n\n`; un upstream que
|
|
70
|
+
emita `\r\n\r\n` jamás cerraría un bloque y no mediría nada aunque el precio
|
|
71
|
+
estuviera [I].
|
|
72
|
+
|
|
73
|
+
## Nota BYOK
|
|
74
|
+
|
|
75
|
+
**API key exclusivamente.** Anthropic prohíbe el OAuth de Claude Pro/Max en
|
|
76
|
+
harnesses de terceros; Qwen Code es BYOK desde abr-2026 y esta receta también.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bivelio/savings-layer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "BV-SALA — provider-neutral LLM execution optimizer. A decision layer in front of any LLM provider that cuts the cost of every request while preserving required quality, and reports the saving per call with token-level accounting you can check against your bill. Proprietary and confidential — property of BiVelio Inc.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"types": "./dist/index.d.ts",
|
|
42
42
|
"files": [
|
|
43
43
|
"dist",
|
|
44
|
+
"docs/terminales",
|
|
44
45
|
"README.md",
|
|
45
46
|
"LICENSE"
|
|
46
47
|
],
|