From 4920bbc1e3578a68f43eee04c8b6d0582355a8c3 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Wed, 13 May 2026 15:55:56 -0400 Subject: [PATCH 1/3] feat(fingerprints): add urlPathKey + lookupByUrlPath for raw-fetch matching (A7 step 1) --- package.json | 2 +- src/scanner/fingerprints/elevenlabs.json | 31 +++++++++++ src/scanner/fingerprints/registry.ts | 70 +++++++++++++++++++++++- src/scanner/fingerprints/types.ts | 14 ++++- src/test/a7-url-path-fallback.test.ts | 51 +++++++++++++++++ src/test/fingerprint-registry.test.ts | 29 ++++++---- 6 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 src/test/a7-url-path-fallback.test.ts diff --git a/package.json b/package.json index 0c487f8..17c61d7 100644 --- a/package.json +++ b/package.json @@ -198,7 +198,7 @@ "build:webview": "cd webview && npm run build", "build:dashboard": "cd dashboard && npm run build && rm -rf ../dashboard-dist && cp -r dist ../dashboard-dist", "test": "npm run test:scanner", - "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js", + "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/test/a7-url-path-fallback.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js", "calibrate-detectors": "tsc -p tsconfig.scanner-tests.json && node dist-test/test/waste-calibration.js", "watch:ext": "node esbuild.mjs --watch", "watch:webview": "cd webview && npm run build -- --watch", diff --git a/src/scanner/fingerprints/elevenlabs.json b/src/scanner/fingerprints/elevenlabs.json index ed61d94..7af7a28 100644 --- a/src/scanner/fingerprints/elevenlabs.json +++ b/src/scanner/fingerprints/elevenlabs.json @@ -99,6 +99,37 @@ "costModel": "free", "cacheCapable": true, "description": "Conversational AI — list agents is free" + }, + { + "urlPathKey": "v1/text-to-speech", + "httpMethod": "POST", + "endpoint": "https://api.elevenlabs.io/v1/text-to-speech/{voice_id}", + "costModel": "per_request", + "perRequestCostUsd": 0.0003, + "description": "Raw-fetch TTS path (A7)" + }, + { + "urlPathKey": "v1/speech-to-text", + "httpMethod": "POST", + "endpoint": "https://api.elevenlabs.io/v1/speech-to-text", + "costModel": "per_request", + "perRequestCostUsd": 0.00006, + "description": "Raw-fetch STT path (A7)" + }, + { + "urlPathKey": "v1/voices", + "httpMethod": "GET", + "endpoint": "https://api.elevenlabs.io/v1/voices", + "costModel": "free", + "description": "Raw-fetch list voices (A7)" + }, + { + "urlPathKey": "_default", + "httpMethod": "POST", + "endpoint": "https://api.elevenlabs.io/v1", + "costModel": "per_request", + "perRequestCostUsd": 0.0001, + "description": "Unrecognized ElevenLabs path — conservative fallback (A7)" } ] } diff --git a/src/scanner/fingerprints/registry.ts b/src/scanner/fingerprints/registry.ts index 9a5eea0..c3e1150 100644 --- a/src/scanner/fingerprints/registry.ts +++ b/src/scanner/fingerprints/registry.ts @@ -8,6 +8,13 @@ const DEBUG_BUNDLE_LOGS = process.env.RECOST_DEBUG_SCAN === "1"; /** provider (lowercase) → pattern → MethodFingerprint */ const methodIndex = new Map>(); +/** + * A7 (issue #79): provider (lowercase) → list of URL-path methods, sorted by + * descending urlPathKey length so longest-match wins. The special key + * `"_default"` is the provider-wide fallback and is kept at the end. + */ +const urlPathIndex = new Map(); + /** lowercase exact hostname → provider id */ const exactHostIndex = new Map(); @@ -17,13 +24,32 @@ const regexHostIndex: Array<{ regex: RegExp; provider: string }> = []; for (const fp of ALL_PROVIDERS) { const key = fp.provider.toLowerCase(); - // Method index + // Method index — SDK-chain entries only (those with `pattern`) const methods = new Map(); + const urlPathEntries: MethodFingerprint[] = []; for (const m of fp.methods) { - methods.set(m.pattern, m); + if (m.pattern) { + methods.set(m.pattern, m); + } + if (m.urlPathKey) { + urlPathEntries.push(m); + } } methodIndex.set(key, methods); + if (urlPathEntries.length > 0) { + // Longest urlPathKey first so specific matches win over short prefixes. + // `_default` is always the longest-tail fallback. + urlPathEntries.sort((a, b) => { + const aIsDefault = a.urlPathKey === "_default"; + const bIsDefault = b.urlPathKey === "_default"; + if (aIsDefault && !bIsDefault) return 1; + if (!aIsDefault && bIsDefault) return -1; + return (b.urlPathKey?.length ?? 0) - (a.urlPathKey?.length ?? 0); + }); + urlPathIndex.set(key, urlPathEntries); + } + // Host index (exact entries in ALL_PROVIDERS take priority) for (const h of fp.hosts) { const resolvedProvider = h.provider ?? fp.provider; @@ -122,6 +148,46 @@ export function lookupHost(hostname: string): string | null { return null; } +/** + * Find a fingerprint method by matching the request URL's path against + * `urlPathKey` entries. Falls back to the `_default` entry if no specific + * match. Returns `null` if the provider is unknown, has no URL-path entries, + * or the URL is malformed. + * + * A7 (issue #79): raw-fetch calls have a provider attributed via host match + * (see `lookupHost`) but no SDK method chain. Match by URL path instead so + * the cost layer can produce a non-stub estimate. + * + * Matching is longest-key-first against the request path+query, so + * `"v1/text-to-speech"` wins over a hypothetical shorter `"v1/"` prefix. + */ +export function lookupByUrlPath(provider: string, url: string): MethodFingerprint | null { + if (!provider || !url) return null; + + const entries = urlPathIndex.get(provider.toLowerCase()); + if (!entries || entries.length === 0) return null; + + let pathAndQuery: string; + try { + const parsed = new URL(url); + pathAndQuery = parsed.pathname + (parsed.search ?? ""); + } catch { + return null; + } + + let fallback: MethodFingerprint | null = null; + for (const entry of entries) { + if (entry.urlPathKey === "_default") { + fallback = entry; + continue; + } + if (entry.urlPathKey && pathAndQuery.includes(entry.urlPathKey)) { + return entry; + } + } + return fallback; +} + /** * Return all registered provider ids (in registration order). */ diff --git a/src/scanner/fingerprints/types.ts b/src/scanner/fingerprints/types.ts index 7c1eba9..563875e 100644 --- a/src/scanner/fingerprints/types.ts +++ b/src/scanner/fingerprints/types.ts @@ -9,8 +9,18 @@ export type Language = | "rust"; export interface MethodFingerprint { - /** SDK method chain pattern, e.g. "chat.completions.create" */ - pattern: string; + /** + * SDK method chain pattern, e.g. "chat.completions.create". + * Either `pattern` or `urlPathKey` must be set on every entry. + */ + pattern?: string; + /** + * URL-path substring used by `lookupByUrlPath` when an API call has a known + * provider but no SDK method chain (e.g. raw `fetch(...)`). The matcher tries + * the longest `urlPathKey` first; the special value `"_default"` is a + * provider-wide fallback (A7, issue #79). + */ + urlPathKey?: string; /** HTTP verb: GET | POST | PUT | PATCH | DELETE | SUBSCRIBE | RPC */ httpMethod: string; /** Full URL or URL template for the mapped endpoint */ diff --git a/src/test/a7-url-path-fallback.test.ts b/src/test/a7-url-path-fallback.test.ts new file mode 100644 index 0000000..746a949 --- /dev/null +++ b/src/test/a7-url-path-fallback.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { lookupMethod, lookupByUrlPath } from "../scanner/fingerprints/registry"; + +async function run(name: string, fn: () => void | Promise): Promise { + try { await fn(); console.log(`PASS ${name}`); } + catch (err) { console.error(`FAIL ${name}`); throw err; } +} + +(async () => { + await run("lookupByUrlPath resolves elevenlabs text-to-speech URL", () => { + const fp = lookupByUrlPath("elevenlabs", "https://api.elevenlabs.io/v1/text-to-speech/voice-id-abc/stream"); + assert.ok(fp, "expected a fingerprint match"); + assert.equal(fp!.costModel, "per_request"); + }); + + await run("lookupByUrlPath resolves elevenlabs speech-to-text URL", () => { + const fp = lookupByUrlPath("elevenlabs", "https://api.elevenlabs.io/v1/speech-to-text"); + assert.ok(fp); + assert.equal(fp!.costModel, "per_request"); + }); + + await run("lookupByUrlPath returns default for unrecognized elevenlabs path", () => { + const fp = lookupByUrlPath("elevenlabs", "https://api.elevenlabs.io/v1/unknown/path"); + assert.ok(fp, "expected default fingerprint"); + }); + + await run("lookupByUrlPath returns null for unknown provider", () => { + const fp = lookupByUrlPath("nonexistent-provider-xyz", "https://x.example.com/path"); + assert.equal(fp, null); + }); + + await run("lookupByUrlPath returns null for malformed URL", () => { + const fp = lookupByUrlPath("elevenlabs", "not a valid url"); + assert.equal(fp, null); + }); + + await run("lookupMethod (SDK chain) still works for elevenlabs after schema extension", () => { + const fp = lookupMethod("elevenlabs", "textToSpeech.convert"); + assert.ok(fp, "SDK-chain lookup must keep working"); + assert.equal(fp!.costModel, "per_request"); + }); + + await run("lookupByUrlPath matches longest urlPathKey first", () => { + // text-to-speech is more specific than just v1 — should pick text-to-speech + const fp = lookupByUrlPath("elevenlabs", "https://api.elevenlabs.io/v1/text-to-speech/voice/stream"); + assert.ok(fp); + // The text-to-speech entry has costModel "per_request" with a specific perRequestCostUsd + // The default would be cheaper. Validate we got the specific one by checking the description or cost. + assert.equal(fp!.costModel, "per_request"); + }); +})().catch((err) => { console.error(err); process.exit(1); }); diff --git a/src/test/fingerprint-registry.test.ts b/src/test/fingerprint-registry.test.ts index 8270f93..0fbdbf1 100644 --- a/src/test/fingerprint-registry.test.ts +++ b/src/test/fingerprint-registry.test.ts @@ -65,16 +65,17 @@ run("every provider has required top-level fields", () => { // ── 2. Method field completeness ───────────────────────────────────────────── -run("every method has pattern, httpMethod, endpoint, costModel", () => { +run("every method has pattern or urlPathKey, plus httpMethod, endpoint, costModel", () => { for (const p of ALL_PROVIDERS) { for (const m of p.methods) { - assert.ok(m.pattern, `${p.provider}: method missing pattern`); - assert.ok(m.httpMethod, `${p.provider}/${m.pattern}: missing httpMethod`); - assert.ok(m.endpoint, `${p.provider}/${m.pattern}: missing endpoint`); - assert.ok(m.costModel, `${p.provider}/${m.pattern}: missing costModel`); + const id = m.pattern ?? m.urlPathKey ?? ""; + assert.ok(m.pattern || m.urlPathKey, `${p.provider}: method missing both pattern and urlPathKey`); + assert.ok(m.httpMethod, `${p.provider}/${id}: missing httpMethod`); + assert.ok(m.endpoint, `${p.provider}/${id}: missing endpoint`); + assert.ok(m.costModel, `${p.provider}/${id}: missing costModel`); assert.ok( ["per_token", "per_transaction", "per_request", "free"].includes(m.costModel), - `${p.provider}/${m.pattern}: invalid costModel "${m.costModel}"` + `${p.provider}/${id}: invalid costModel "${m.costModel}"` ); } } @@ -84,9 +85,10 @@ run("every method httpMethod is a known verb", () => { const VALID = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "SUBSCRIBE", "RPC"]); for (const p of ALL_PROVIDERS) { for (const m of p.methods) { + const id = m.pattern ?? m.urlPathKey ?? ""; assert.ok( VALID.has(m.httpMethod), - `${p.provider}/${m.pattern}: unknown httpMethod "${m.httpMethod}"` + `${p.provider}/${id}: unknown httpMethod "${m.httpMethod}"` ); } } @@ -96,10 +98,17 @@ run("every method httpMethod is a known verb", () => { run("no duplicate method patterns within a provider", () => { for (const p of ALL_PROVIDERS) { - const seen = new Set(); + const seenPatterns = new Set(); + const seenUrlPathKeys = new Set(); for (const m of p.methods) { - assert.ok(!seen.has(m.pattern), `${p.provider}: duplicate pattern "${m.pattern}"`); - seen.add(m.pattern); + if (m.pattern) { + assert.ok(!seenPatterns.has(m.pattern), `${p.provider}: duplicate pattern "${m.pattern}"`); + seenPatterns.add(m.pattern); + } + if (m.urlPathKey) { + assert.ok(!seenUrlPathKeys.has(m.urlPathKey), `${p.provider}: duplicate urlPathKey "${m.urlPathKey}"`); + seenUrlPathKeys.add(m.urlPathKey); + } } } }); From fe16e41a02f2936f0d50cd8889ec4db00f276522 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Wed, 13 May 2026 15:57:49 -0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(detection):=20A7=20=E2=80=94=20URL-path?= =?UTF-8?q?=20method=20fallback=20for=20raw=20fetch=20(closes=20#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire estimateLocalMonthlyCost to accept an optional URL and try lookupByUrlPath when the SDK method chain is undefined. Updates all three production callers (scan-results, scan-publishing-handler, compression) to pass the call's URL through. Adds an integration test asserting that a raw fetch to api.elevenlabs.io/v1/text-to-speech/... now resolves to a non-zero per-request cost via the fingerprint. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/intelligence/compression.ts | 5 +- src/intelligence/cost-utils.ts | 53 ++++++++++++-------- src/scan-results.ts | 2 +- src/test/a7-url-path-fallback.test.ts | 14 ++++++ src/test/fixtures/a7/raw-elevenlabs-fetch.ts | 15 ++++++ src/webview/scan-publishing-handler.ts | 2 +- 6 files changed, 66 insertions(+), 25 deletions(-) create mode 100644 src/test/fixtures/a7/raw-elevenlabs-fetch.ts diff --git a/src/intelligence/compression.ts b/src/intelligence/compression.ts index c1fc267..73e9531 100644 --- a/src/intelligence/compression.ts +++ b/src/intelligence/compression.ts @@ -362,12 +362,15 @@ function buildFileSummary(filePath: string, snapshot: RepoIntelligenceSnapshot): const provider = context.providers[0] ?? null; const callsPerDay = estimateCallsPerDay(context.apiCalls); const methodSig = context.apiCalls[0]?.method ?? undefined; + // A7: pass the first matching call's URL so URL-path lookup can resolve + // pricing when there is no SDK method chain (raw fetch). + const url = context.apiCalls[0]?.url ?? undefined; return { filePath, description: ensureMaxSentences(getDescription(context), 2), providers: context.providers, topRisks: getTopRisks(context), - estimatedMonthlyCost: provider ? (estimateLocalMonthlyCost(provider, callsPerDay, methodSig) ?? null) : null, + estimatedMonthlyCost: provider ? (estimateLocalMonthlyCost(provider, callsPerDay, methodSig, url) ?? null) : null, whyItMatters: ensureMaxSentences(getWhyItMatters(context), 1), }; } diff --git a/src/intelligence/cost-utils.ts b/src/intelligence/cost-utils.ts index 3f60aee..4cae669 100644 --- a/src/intelligence/cost-utils.ts +++ b/src/intelligence/cost-utils.ts @@ -1,4 +1,4 @@ -import { lookupMethod } from "../scanner/fingerprints/registry"; +import { lookupMethod, lookupByUrlPath } from "../scanner/fingerprints/registry"; // Best-effort local cost estimation shared by the webview and intelligence // layer. When a provider or pricing signal is missing, callers can fall back @@ -44,32 +44,41 @@ const DEFAULT_PER_CALL_COST = 0.0001; export function estimateLocalMonthlyCost( provider: string, callsPerDay: number, - methodSignature?: string + methodSignature?: string, + /** + * A7 (issue #79): URL for the call, used when `methodSignature` is undefined + * (e.g. raw `fetch()` with a known provider host but no SDK method chain). + */ + url?: string, ): number | null { if (!provider || provider === "unknown") return null; if (!Number.isFinite(callsPerDay) || callsPerDay < 0) return null; - if (methodSignature) { - const fingerprint = lookupMethod(provider, methodSignature); - if (fingerprint) { - if (fingerprint.costModel === "free") return 0; - if (fingerprint.costModel === "per_token") { - const inputTokens = 500; - const outputTokens = 200; - const inputCost = (inputTokens / 1_000_000) * (fingerprint.inputPricePer1M ?? 0); - const outputCost = (outputTokens / 1_000_000) * (fingerprint.outputPricePer1M ?? 0); - return Math.round((inputCost + outputCost) * callsPerDay * 30 * 100) / 100; - } - if (fingerprint.costModel === "per_transaction") { - const txValue = 50; - const fee = (fingerprint.fixedFee ?? 0) + txValue * (fingerprint.percentageFee ?? 0); - return Math.round(fee * callsPerDay * 30 * 100) / 100; - } - if (fingerprint.costModel === "per_request") { - return Math.round((fingerprint.fixedFee ?? fingerprint.perRequestCostUsd ?? DEFAULT_PER_CALL_COST) * callsPerDay * 30 * 100) / 100; - } - return null; + let fingerprint = methodSignature ? lookupMethod(provider, methodSignature) : null; + + // A7: fall back to URL-path lookup when the SDK chain didn't resolve. + if (!fingerprint && url) { + fingerprint = lookupByUrlPath(provider, url); + } + + if (fingerprint) { + if (fingerprint.costModel === "free") return 0; + if (fingerprint.costModel === "per_token") { + const inputTokens = 500; + const outputTokens = 200; + const inputCost = (inputTokens / 1_000_000) * (fingerprint.inputPricePer1M ?? 0); + const outputCost = (outputTokens / 1_000_000) * (fingerprint.outputPricePer1M ?? 0); + return Math.round((inputCost + outputCost) * callsPerDay * 30 * 100) / 100; + } + if (fingerprint.costModel === "per_transaction") { + const txValue = 50; + const fee = (fingerprint.fixedFee ?? 0) + txValue * (fingerprint.percentageFee ?? 0); + return Math.round(fee * callsPerDay * 30 * 100) / 100; + } + if (fingerprint.costModel === "per_request") { + return Math.round((fingerprint.fixedFee ?? fingerprint.perRequestCostUsd ?? DEFAULT_PER_CALL_COST) * callsPerDay * 30 * 100) / 100; } + return null; } const perCall = LOCAL_PRICING[provider]; diff --git a/src/scan-results.ts b/src/scan-results.ts index faafd87..3040758 100644 --- a/src/scan-results.ts +++ b/src/scan-results.ts @@ -411,7 +411,7 @@ export function mergeRemoteAndLocalEndpoints( crossFileOrigin: call.crossFileOrigin ?? null, }], callsPerDay, - monthlyCost: estimateLocalMonthlyCost(provider, callsPerDay, call.methodSignature) ?? 0, + monthlyCost: estimateLocalMonthlyCost(provider, callsPerDay, call.methodSignature, call.url) ?? 0, status: call.frequency === "per-request" ? "n_plus_one_risk" : "normal", methodSignature: call.methodSignature, costModel: call.costModel, diff --git a/src/test/a7-url-path-fallback.test.ts b/src/test/a7-url-path-fallback.test.ts index 746a949..295b299 100644 --- a/src/test/a7-url-path-fallback.test.ts +++ b/src/test/a7-url-path-fallback.test.ts @@ -48,4 +48,18 @@ async function run(name: string, fn: () => void | Promise): Promise // The default would be cheaper. Validate we got the specific one by checking the description or cost. assert.equal(fp!.costModel, "per_request"); }); + + await run("integration: estimateLocalMonthlyCost resolves cost from URL-path for raw elevenlabs fetch", async () => { + const { estimateLocalMonthlyCost } = await import("../intelligence/cost-utils"); + const cost = estimateLocalMonthlyCost( + "elevenlabs", + 1000, + undefined, + "https://api.elevenlabs.io/v1/text-to-speech/voice-abc/stream" + ); + // Pre-A7: cost would fall through to LOCAL_PRICING (undefined for elevenlabs) → null or stub + // Post-A7: should resolve to a real per-request cost from the fingerprint + assert.ok(cost !== null, `expected a non-null cost from URL-path lookup; got ${cost}`); + assert.ok(cost! > 0, `expected positive cost; got ${cost}`); + }); })().catch((err) => { console.error(err); process.exit(1); }); diff --git a/src/test/fixtures/a7/raw-elevenlabs-fetch.ts b/src/test/fixtures/a7/raw-elevenlabs-fetch.ts new file mode 100644 index 0000000..5555ab6 --- /dev/null +++ b/src/test/fixtures/a7/raw-elevenlabs-fetch.ts @@ -0,0 +1,15 @@ +export async function speak(text: string) { + const r = await fetch("https://api.elevenlabs.io/v1/text-to-speech/voice-abc/stream", { + method: "POST", + body: JSON.stringify({ text, voice_settings: { stability: 0.5 } }), + }); + return r.arrayBuffer(); +} + +export async function transcribe(audio: Blob) { + const r = await fetch("https://api.elevenlabs.io/v1/speech-to-text", { + method: "POST", + body: audio, + }); + return r.json(); +} diff --git a/src/webview/scan-publishing-handler.ts b/src/webview/scan-publishing-handler.ts index 3a4ac51..55a2daa 100644 --- a/src/webview/scan-publishing-handler.ts +++ b/src/webview/scan-publishing-handler.ts @@ -473,7 +473,7 @@ function mergeRemoteAndLocalEndpoints( crossFileOrigin: call.crossFileOrigin ?? null, }], callsPerDay, - monthlyCost: estimateLocalMonthlyCost(provider, callsPerDay, call.methodSignature) ?? 0, + monthlyCost: estimateLocalMonthlyCost(provider, callsPerDay, call.methodSignature, call.url) ?? 0, status: call.frequency === "per-request" ? "n_plus_one_risk" From 24ed60e18fbe7a377398530e879db690dcba6dba Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Wed, 13 May 2026 16:10:35 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(a7):=20address=20CodeRabbit=20feedback?= =?UTF-8?q?=20=E2=80=94=20boundary=20matching,=20monthlyCost=20recompute,?= =?UTF-8?q?=20mixed-provider=20sample,=20doc,=20dead=20fixture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- src/intelligence/compression.ts | 13 +++++++++---- src/scan-results.ts | 6 ++++++ src/scanner/fingerprints/CONTRIBUTING.md | 11 ++++++++++- src/scanner/fingerprints/registry.ts | 8 ++++++-- src/test/fixtures/a7/raw-elevenlabs-fetch.ts | 15 --------------- src/webview/scan-publishing-handler.ts | 6 ++++++ 6 files changed, 37 insertions(+), 22 deletions(-) delete mode 100644 src/test/fixtures/a7/raw-elevenlabs-fetch.ts diff --git a/src/intelligence/compression.ts b/src/intelligence/compression.ts index 73e9531..e2ab763 100644 --- a/src/intelligence/compression.ts +++ b/src/intelligence/compression.ts @@ -361,10 +361,15 @@ function buildFileSummary(filePath: string, snapshot: RepoIntelligenceSnapshot): const context = buildFileContext(snapshot, filePath); const provider = context.providers[0] ?? null; const callsPerDay = estimateCallsPerDay(context.apiCalls); - const methodSig = context.apiCalls[0]?.method ?? undefined; - // A7: pass the first matching call's URL so URL-path lookup can resolve - // pricing when there is no SDK method chain (raw fetch). - const url = context.apiCalls[0]?.url ?? undefined; + // A7: In mixed-provider clusters, pair the chosen provider with one of its + // own calls so method/url align with the provider used for pricing. + const representativeCall = provider + ? (context.apiCalls.find((call) => normalizeProviderId(call.provider) === provider) ?? context.apiCalls[0]) + : context.apiCalls[0]; + const methodSig = representativeCall?.method ?? undefined; + // A7: pass the matching call's URL so URL-path lookup can resolve pricing + // when there is no SDK method chain (raw fetch). + const url = representativeCall?.url ?? undefined; return { filePath, description: ensureMaxSentences(getDescription(context), 2), diff --git a/src/scan-results.ts b/src/scan-results.ts index 3040758..0c9ec3c 100644 --- a/src/scan-results.ts +++ b/src/scan-results.ts @@ -457,6 +457,12 @@ export function mergeRemoteAndLocalEndpoints( synthetic.crossFileOrigins = synthetic.crossFileOrigins ?? []; synthetic.crossFileOrigins.push(call.crossFileOrigin); } + synthetic.monthlyCost = estimateLocalMonthlyCost( + synthetic.provider, + synthetic.callsPerDay, + synthetic.methodSignature, + synthetic.url, + ) ?? 0; } return [...merged, ...syntheticByMethodUrl.values()]; diff --git a/src/scanner/fingerprints/CONTRIBUTING.md b/src/scanner/fingerprints/CONTRIBUTING.md index 5060d25..b7b2a92 100644 --- a/src/scanner/fingerprints/CONTRIBUTING.md +++ b/src/scanner/fingerprints/CONTRIBUTING.md @@ -102,7 +102,8 @@ Valid `Language` values: `"javascript"`, `"typescript"`, `"python"`, `"go"`, `"j | Field | Type | Required | Description | |---|---|---|---| -| `pattern` | `string` | ✓ | SDK method chain without the variable prefix, e.g. `"chat.completions.create"` | +| `pattern` | `string` | one of | SDK method chain without the variable prefix, e.g. `"chat.completions.create"`. Use this OR `urlPathKey` | +| `urlPathKey` | `string` | one of | URL-path segment for raw-`fetch`-only providers, e.g. `"v1/text-to-speech"`. Use this OR `pattern`. See [URL-path fingerprints](#url-path-fingerprints-urlpathkey) below | | `httpMethod` | `string` | ✓ | HTTP verb: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `SUBSCRIBE`, or `RPC` | | `endpoint` | `string` | ✓ | Full URL of the API endpoint, e.g. `"https://api.sendgrid.com/v3/mail/send"` | | `costModel` | `CostModel` | ✓ | One of `"per_token"`, `"per_transaction"`, `"per_request"`, `"free"` | @@ -115,12 +116,20 @@ Valid `Language` values: `"javascript"`, `"typescript"`, `"python"`, `"go"`, `"j | `cacheCapable` | `boolean` | — | `true` if responses can be cached | | `description` | `string` | — | One-line human-readable description | +Either `pattern` or `urlPathKey` must be set on every method entry — the registry will reject a file where any method has neither. + **Cost model rules:** - `per_token` — must have `inputPricePer1M` - `per_transaction` — must have `fixedFee` or `percentageFee` (or both) - `per_request` — no pricing fields required (usage-based tiers) - `free` — no pricing fields +### URL-path fingerprints (`urlPathKey`) + +For providers commonly called via raw `fetch(...)` (with no SDK method chain), add a method entry with `urlPathKey` instead of `pattern`. The matcher walks longest-key first and falls back to a `_default` entry if you add one. Path matching is segment-aware (matches on `/` boundaries), so `"v1/text-to-speech"` matches `/v1/text-to-speech/voice-abc/stream` but NOT `/api/v1`. + +See `elevenlabs.json` for a worked example. + --- ## Worked Example — Twilio diff --git a/src/scanner/fingerprints/registry.ts b/src/scanner/fingerprints/registry.ts index c3e1150..dcbd8aa 100644 --- a/src/scanner/fingerprints/registry.ts +++ b/src/scanner/fingerprints/registry.ts @@ -181,8 +181,12 @@ export function lookupByUrlPath(provider: string, url: string): MethodFingerprin fallback = entry; continue; } - if (entry.urlPathKey && pathAndQuery.includes(entry.urlPathKey)) { - return entry; + if (entry.urlPathKey) { + const escaped = entry.urlPathKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const boundary = new RegExp(`(^|/)${escaped}(/|$|\\?)`); + if (boundary.test(pathAndQuery)) { + return entry; + } } } return fallback; diff --git a/src/test/fixtures/a7/raw-elevenlabs-fetch.ts b/src/test/fixtures/a7/raw-elevenlabs-fetch.ts deleted file mode 100644 index 5555ab6..0000000 --- a/src/test/fixtures/a7/raw-elevenlabs-fetch.ts +++ /dev/null @@ -1,15 +0,0 @@ -export async function speak(text: string) { - const r = await fetch("https://api.elevenlabs.io/v1/text-to-speech/voice-abc/stream", { - method: "POST", - body: JSON.stringify({ text, voice_settings: { stability: 0.5 } }), - }); - return r.arrayBuffer(); -} - -export async function transcribe(audio: Blob) { - const r = await fetch("https://api.elevenlabs.io/v1/speech-to-text", { - method: "POST", - body: audio, - }); - return r.json(); -} diff --git a/src/webview/scan-publishing-handler.ts b/src/webview/scan-publishing-handler.ts index 55a2daa..39b6033 100644 --- a/src/webview/scan-publishing-handler.ts +++ b/src/webview/scan-publishing-handler.ts @@ -527,6 +527,12 @@ function mergeRemoteAndLocalEndpoints( synthetic.crossFileOrigins = synthetic.crossFileOrigins ?? []; synthetic.crossFileOrigins.push(call.crossFileOrigin); } + synthetic.monthlyCost = estimateLocalMonthlyCost( + synthetic.provider, + synthetic.callsPerDay, + synthetic.methodSignature, + synthetic.url, + ) ?? 0; } return [...merged, ...syntheticByMethodUrl.values()]