From fac027bc85a5ac58c700d6e7b90f234d35757ed7 Mon Sep 17 00:00:00 2001 From: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:48:12 -0400 Subject: [PATCH 01/15] fix: treat empty SDK endpoints as unspecified defaults Raw '', absent, and null provider URLs are unspecified SDK defaults instead of invalid identity. Public rates still require an exact provider/model/endpoint match, so a missing public URL cannot certify a custom gateway and CLI cost cannot override a route mismatch. Signed-off-by: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> --- ...-connection-billing-usage-execution-log.md | 61 ++++++++ .../2026-09-08-working-tree-preserved.patch | 82 ++++++++++ src/core/pricing.js | 41 ++++- src/server/opencode-cli.js | 7 +- test/core/pricing.test.js | 100 ++++++++++++ test/fixtures/public-metadata.js | 11 ++ test/server/catalog-v2.test.js | 2 +- test/server/opencode-cli.test.js | 145 ++++++++++++++++++ 8 files changed, 437 insertions(+), 12 deletions(-) create mode 100644 docs/plans/2026-09-08-connection-billing-usage-execution-log.md create mode 100644 docs/plans/2026-09-08-working-tree-preserved.patch diff --git a/docs/plans/2026-09-08-connection-billing-usage-execution-log.md b/docs/plans/2026-09-08-connection-billing-usage-execution-log.md new file mode 100644 index 0000000..a7c586d --- /dev/null +++ b/docs/plans/2026-09-08-connection-billing-usage-execution-log.md @@ -0,0 +1,61 @@ +# Connection Billing Execution Log + +**Branch:** `codex/connection-billing-usage` +**Baseline HEAD:** `b931cbc3218b028c5060aaa3eb7997d189dc9ea3` (`main`, package 0.3.0) +**Started:** 2026-09-08 +**Executor:** Grok 4.6 + +## Environment + +| Item | Value | +| --- | --- | +| Node | v26.8.1 (`/home/panda927/.local/bin/node`) | +| npm | 11.19.0 | +| OpenCode | 1.18.28 | +| Platform | linux | +| Remote | `https://github.com/BitL8-ByteShort/opencode-model-control.git` | + +Node is not 22.12.0 or 24.x. Recorded as an environment limitation; diagnose product vs environment failures separately. `engines` is `>=22.12.0`. + +## Preserved working-tree patch + +Saved to `docs/plans/2026-09-08-working-tree-preserved.patch` (82 lines). Original uncommitted files: + +- `src/core/pricing.js` +- `src/server/opencode-cli.js` + +**Disposition:** both hunks **excluded** from the worktree. + +- `pricing.js`: loosened public-URL matching so a live URL is accepted when models.dev omitted the URL. Plan §1.1 / §3.3: an unknown public endpoint must not certify an arbitrary custom endpoint. +- `opencode-cli.js`: allowed positive CLI costs to override an identity conflict. Plan §1.1: positive recorded costs do not prove matching route identity. + +These are not verified fixes. The correct Task 2 contract treats raw `''`/absent/null as an unspecified SDK default without relaxing public-price identity. + +## Task 1 + +- `git status --short` at start: `M src/core/pricing.js`, `M src/server/opencode-cli.js`, untracked plan. +- HEAD `b931cbc3218b028c5060aaa3eb7997d189dc9ea3`, package 0.3.0, branch created `codex/connection-billing-usage`. +- Patch saved; both hunks excluded (see above). +- `npm ci`: 44 packages, 0 vulnerabilities. +- `npm run verify` on Node v26.8.1: 2 failures (production-entry timeout; lock recovery `typeof release` object vs function). Environment, not product. +- `npm run verify` on `/usr/bin/node` v24.14.0: 292 pass, 1 fail — `test/server/production-entry.test.js` times out waiting for `/OpenCode Model Control:/`. Only output is Node `--experimental-loader` deprecation warning. Pre-existing environment limitation. Subsequent work uses Node 24. +- Failing regressions added and confirmed: + - empty `url: ''` => `urlValid: false` (`{"url":""}` false !== true) + - `xai/grok-4.6` empty SDK default => `unknown` !== `paid` + - CLI parse empty URL `urlValid` false !== true + - cached invalid then fresh empty discovery still `urlValid` false + - provider-owned fetch => `OMC_DISPATCH_IDENTITY_CONFLICT` +- Guard that already passed on HEAD: custom gateway cannot inherit unspecified public rates / CLI cost cannot override mismatch. + +## Task 2 + +**Files:** `src/core/pricing.js`, `src/server/opencode-cli.js`, `test/core/pricing.test.js`, `test/server/opencode-cli.test.js`, `test/server/catalog-v2.test.js`, `test/fixtures/public-metadata.js` + +**Fix:** Treat raw `''` / absent / null as unspecified SDK default (`url: null`, `urlValid: true`) unless cached `urlValid: false`. Malformed, whitespace, credential, and query endpoints remain invalid. Public rate matching uses exact id/npm/url; missing public URL does not certify a custom gateway (`public-price-route-mismatch`). CLI cost cannot override that mismatch. + +**Commands:** `node --test test/core/pricing.test.js test/server/opencode-cli.test.js test/server/models-dev.test.js test/server/catalog-v2.test.js` — 38 pass, 0 fail. + +**Remaining:** provider-owned fetch still blocked (Task 5). Fetch regression left unstaged for that task. + +## Task 3 + diff --git a/docs/plans/2026-09-08-working-tree-preserved.patch b/docs/plans/2026-09-08-working-tree-preserved.patch new file mode 100644 index 0000000..cf8c520 --- /dev/null +++ b/docs/plans/2026-09-08-working-tree-preserved.patch @@ -0,0 +1,82 @@ +diff --git a/src/core/pricing.js b/src/core/pricing.js +index 7287e98..c4b862b 100644 +--- a/src/core/pricing.js ++++ b/src/core/pricing.js +@@ -284,8 +284,20 @@ export function resolveModelEvidence(live, snapshot) { + const record = snapshot?.models?.[live.id]; + if (!record) return unknownPricing("model-not-in-public-source"); + const api = normalizeApiIdentity(live.api); ++ // Identity must match on provider/model id and npm adapter. ++ // For the endpoint URL: only treat as conflict if the public source ++ // (models.dev) declared a specific URL and the live one differs or is absent. ++ // Many providers in models.dev omit the api URL (null); a valid live URL ++ // from OpenCode should not be rejected in that case. ++ const publicUrl = record.api.url; ++ const liveUrl = api.url; ++ const urlConflict = ++ publicUrl != null && ++ (liveUrl == null || liveUrl !== publicUrl); + const conflict = +- ["id", "npm", "url"].some((key) => api[key] !== record.api[key]) || ++ api.id !== record.api.id || ++ api.npm !== record.api.npm || ++ urlConflict || + !api.id || + !api.npm || + !api.urlValid || +diff --git a/src/server/opencode-cli.js b/src/server/opencode-cli.js +index 54abdcf..4dcdfc9 100644 +--- a/src/server/opencode-cli.js ++++ b/src/server/opencode-cli.js +@@ -417,23 +417,37 @@ export function mergeDiscoveredCatalog( + reasons: ["identity-or-rate-conflict"], + }; + } else pricing = unknownPricing(); +- // Compatibility only: complete positive CLI rates can establish reported +- // paid status when no independent source record contradicts them. ++ // Compatibility only: complete positive CLI rates can establish reported-paid ++ // when there is no independent source record, or when the public record ++ // could not be used due to an identity conflict (e.g. models.dev omitted ++ // the api URL but OpenCode reported a valid one + positive per-token costs). ++ // This lets newly-added or provider-specific paid models become usable ++ // without relaxing the "complete positive rates + urlValid" requirement. ++ // Clean (non-conflicting) public evidence from models.dev is still preferred. ++ const publicHasRecord = !!(publicMetadata && publicMetadata.models?.[id]); ++ const conflictedWithPublic = pricing?.reasons?.includes("identity-conflict"); + if ( +- (!publicMetadata || !publicMetadata.models?.[id]) && +- pricing.source !== "https://models.dev/api.json" && + reported.class === "paid" && +- api.urlValid ++ api.urlValid && ++ (!publicHasRecord || conflictedWithPublic) + ) { +- pricing = { +- ...reported, +- source: "reported-paid", +- digest: null, +- fetchedAt: observedAt, +- expiresAt: new Date( +- Date.parse(observedAt) + PRICING_TTL_MS, +- ).toISOString(), +- }; ++ // Only override if we do not already have a clean paid record from the ++ // independent public source. ++ if ( ++ !pricing || ++ pricing.class !== "paid" || ++ pricing.source === "https://models.dev/api.json" && (pricing.reasons?.length ?? 0) > 0 ++ ) { ++ pricing = { ++ ...reported, ++ source: "reported-paid", ++ digest: null, ++ fetchedAt: observedAt, ++ expiresAt: new Date( ++ Date.parse(observedAt) + PRICING_TTL_MS, ++ ).toISOString(), ++ }; ++ } + } + const pricingClass = classifyPricingEvidence(pricing, { now }); + // A fresh observation resolves the rejection only when its resulting diff --git a/src/core/pricing.js b/src/core/pricing.js index 7287e98..964ef72 100644 --- a/src/core/pricing.js +++ b/src/core/pricing.js @@ -100,8 +100,12 @@ export function analyzeRates(cost, modes) { }; } +function isUnspecifiedEndpoint(value) { + return value === undefined || value === null || value === ""; +} + function safeUrl(value) { - if (value === undefined || value === null) return null; + if (isUnspecifiedEndpoint(value)) return null; try { const url = new URL(value); if ( @@ -118,12 +122,12 @@ function safeUrl(value) { } } export function normalizeApiIdentity(value) { - const url = safeUrl(value?.url); + const unspecified = isUnspecifiedEndpoint(value?.url); + const url = unspecified ? null : safeUrl(value?.url); // Do not let redaction turn an invalid endpoint into an absent endpoint. // Preserve this flag through repeated normalization and persisted snapshots. const urlValid = - value?.urlValid !== false && - (value?.url === undefined || value?.url === null || url !== null); + value?.urlValid !== false && (unspecified || url !== null); return { id: typeof value?.id === "string" && @@ -139,6 +143,19 @@ export function normalizeApiIdentity(value) { urlValid, }; } +export function publicRatesApply(publicApi, liveApi) { + const pub = normalizeApiIdentity(publicApi); + const live = normalizeApiIdentity(liveApi); + return ( + pub.urlValid && + live.urlValid && + pub.id !== null && + pub.npm !== null && + pub.id === live.id && + pub.npm === live.npm && + pub.url === live.url + ); +} const tri = (value) => (typeof value === "boolean" ? value : null); const limit = (value) => (Number.isInteger(value) && value > 0 ? value : null); export function capabilityDetails(value, source, observedAt, cli = false) { @@ -284,15 +301,23 @@ export function resolveModelEvidence(live, snapshot) { const record = snapshot?.models?.[live.id]; if (!record) return unknownPricing("model-not-in-public-source"); const api = normalizeApiIdentity(live.api); - const conflict = - ["id", "npm", "url"].some((key) => api[key] !== record.api[key]) || + const publicApi = normalizeApiIdentity(record.api); + const identityConflict = !api.id || !api.npm || !api.urlValid || - record.api.urlValid === false; + publicApi.urlValid === false || + api.id !== publicApi.id || + api.npm !== publicApi.npm; + const priceRouteMismatch = + !identityConflict && !publicRatesApply(publicApi, api); return { ...record.pricing, - ...(conflict ? { class: "unknown", reasons: ["identity-conflict"] } : {}), + ...(identityConflict + ? { class: "unknown", reasons: ["identity-conflict"] } + : priceRouteMismatch + ? { class: "unknown", reasons: ["public-price-route-mismatch"] } + : {}), source: MODELS_DEV_URL, digest: snapshot.digest, fetchedAt: snapshot.fetchedAt, diff --git a/src/server/opencode-cli.js b/src/server/opencode-cli.js index 54abdcf..1edaacc 100644 --- a/src/server/opencode-cli.js +++ b/src/server/opencode-cli.js @@ -337,9 +337,9 @@ export function mergeDiscoveredCatalog( pricing: retainCliConflict(publicPricing, prior.pricing), capabilities: { ...prior.capabilities, - supplemental: publicPricing.reasons.includes( - "identity-conflict", - ) + supplemental: + publicPricing.reasons.includes("identity-conflict") || + publicPricing.reasons.includes("public-price-route-mismatch") ? null : (publicMetadata.models?.[id]?.capabilities ?? null), }, @@ -462,6 +462,7 @@ export function mergeDiscoveredCatalog( ); const supplemental = pricing.reasons.includes("identity-conflict") || + pricing.reasons.includes("public-price-route-mismatch") || pricing.reasons.includes("identity-or-rate-conflict") ? null : (publicMetadata?.models?.[id]?.capabilities ?? diff --git a/test/core/pricing.test.js b/test/core/pricing.test.js index e37608d..5608241 100644 --- a/test/core/pricing.test.js +++ b/test/core/pricing.test.js @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { analyzeRates, classifyPricingEvidence, + normalizeApiIdentity, normalizeModelsDev, resolveModelEvidence, } from "../../src/core/pricing.js"; @@ -28,6 +29,105 @@ const evidence = (cost, extra) => live, normalizeModelsDev(raw(cost, extra), { fetchedAt: at }), ); +test("raw absent, null, and empty URLs are unspecified SDK defaults; malformed and cached invalid stay invalid", () => { + const cases = [ + [{}, true], + [{ url: undefined }, true], + [{ url: null }, true], + [{ url: "" }, true], + [{ url: "https://api.x.ai/v1" }, true], + [{ url: "https://gateway.example/v1" }, true], + [{ url: "not a url" }, false], + [{ url: " " }, false], + [{ url: "https://user:secret@api.example/v1" }, false], + [{ url: "https://api.example/v1?key=secret" }, false], + [{ url: null, urlValid: false }, false], + [{ url: "", urlValid: false }, false], + ]; + for (const [value, urlValid] of cases) { + assert.equal( + normalizeApiIdentity(value).urlValid, + urlValid, + JSON.stringify(value), + ); + } + assert.equal(normalizeApiIdentity({ url: "" }).url, null); + assert.equal( + normalizeApiIdentity({ url: null, urlValid: false }).urlValid, + false, + ); +}); + +test("xai/grok-4.6 empty SDK default and unfamiliar nested IDs match unspecified public endpoints", () => { + const at = "2026-09-08T12:00:00.000Z"; + for (const [id, npm, cost] of [ + ["xai/grok-4.6", "@ai-sdk/xai", { input: 3, output: 15 }], + ["unfamiliar/nested/spark-1.3", "@ai-sdk/openai-compatible", { input: 0, output: 0 }], + ]) { + const [provider, ...parts] = id.split("/"); + const key = parts.join("/"); + const snapshot = normalizeModelsDev( + { + [provider]: { + id: provider, + npm, + models: { [key]: { id: key, cost } }, + }, + }, + { fetchedAt: at }, + ); + const record = snapshot.models[id]; + assert.equal(record.api.url, null); + assert.equal(record.api.urlValid, true); + const live = { + id, + api: { id: key, npm, url: "" }, + }; + const evidence = resolveModelEvidence(live, snapshot); + assert.equal(evidence.class, cost.input || cost.output ? "paid" : "free", id); + assert.equal(evidence.reasons.includes("identity-conflict"), false, id); + } +}); + +test("missing public URL does not certify a custom endpoint, and identity mismatch stays distinct from zero rates", () => { + const at = "2026-09-08T12:00:00.000Z"; + const snapshot = normalizeModelsDev( + { + xai: { + id: "xai", + npm: "@ai-sdk/xai", + models: { "grok-4.6": { id: "grok-4.6", cost: { input: 3, output: 15 } } }, + }, + }, + { fetchedAt: at }, + ); + const custom = resolveModelEvidence( + { + id: "xai/grok-4.6", + api: { + id: "grok-4.6", + npm: "@ai-sdk/xai", + url: "https://gateway.example/v1", + }, + }, + snapshot, + ); + assert.equal(custom.class, "unknown"); + assert.ok( + custom.reasons.includes("identity-conflict") || + custom.reasons.includes("public-price-route-mismatch"), + ); + const zero = resolveModelEvidence( + { + id: "xai/grok-4.6", + api: { id: "grok-4.6", npm: "@ai-sdk/xai", url: "" }, + }, + snapshot, + ); + assert.notEqual(zero.class, "unknown"); + assert.equal(zero.rates.input, 3); +}); + test("raw absent, malformed and CLI normalized zeros cannot authorize free", () => { for (const cost of [ undefined, diff --git a/test/fixtures/public-metadata.js b/test/fixtures/public-metadata.js index db0d3aa..3d58405 100644 --- a/test/fixtures/public-metadata.js +++ b/test/fixtures/public-metadata.js @@ -28,6 +28,17 @@ export async function publicMetadataFetch() { } export const noPublicMetadataFetch = async () => new Response("{}", { status: 503 }); +export const sdkDefaultEndpointShapes = Object.freeze({ + absent: {}, + nullUrl: { url: null }, + emptyUrl: { url: "" }, + explicitDefault: { url: "https://api.x.ai/v1" }, + customGateway: { url: "https://gateway.example/v1" }, + malformed: { url: "not a url" }, + whitespace: { url: " " }, + cachedInvalid: { url: null, urlValid: false }, +}); + export function liveModel(id, extra = {}) { return { id, diff --git a/test/server/catalog-v2.test.js b/test/server/catalog-v2.test.js index f708642..021481a 100644 --- a/test/server/catalog-v2.test.js +++ b/test/server/catalog-v2.test.js @@ -315,7 +315,7 @@ test("invalid live URLs stay redacted and block pricing through parsing, persist "https://user:TOP_SECRET@custom.example/v1", "https://custom.example/v1#TOP_SECRET", "TOP_SECRET invalid url", - "", + " ", 123, ]) { const parsed = parse(url); diff --git a/test/server/opencode-cli.test.js b/test/server/opencode-cli.test.js index 891c250..52c279f 100644 --- a/test/server/opencode-cli.test.js +++ b/test/server/opencode-cli.test.js @@ -7,6 +7,10 @@ import { validateCatalog, validateSettings, } from "../../src/core/index.js"; +import { + classifyModelPricing, +} from "../../src/core/catalog.js"; +import { normalizeModelsDev } from "../../src/core/pricing.js"; import { buildOpenCodeConfig } from "../../src/opencode/index.js"; import { discoverOpenCode, @@ -355,6 +359,147 @@ test("catalog refresh restores bundled profiles from legacy capability-marked sn } }); +test("empty SDK-default endpoint parses as unspecified and matches public rates", () => { + const observedAt = "2026-09-08T12:00:00.000Z"; + const parsed = parseOpenCodeVerboseCatalog( + `xai/grok-4.6 +{ + "name": "Grok 4.6", + "status": "active", + "api": { "id": "grok-4.6", "npm": "@ai-sdk/xai", "url": "" }, + "cost": {"input": 3, "output": 15}, + "limit": {"context": 200000}, + "capabilities": {"toolcall": true, "input": {"text": true}, "output": {"text": true}} +}`, + { observedAt }, + ); + assert.equal(parsed[0].api.url, null); + assert.equal(parsed[0].api.urlValid, true); + const publicMetadata = normalizeModelsDev( + { + xai: { + id: "xai", + npm: "@ai-sdk/xai", + models: { "grok-4.6": { id: "grok-4.6", cost: { input: 3, output: 15 } } }, + }, + }, + { fetchedAt: observedAt }, + ); + const catalog = validateCatalog( + mergeDiscoveredCatalog(loadModelCatalog(), parsed, { + publicMetadata, + now: Date.parse(observedAt), + }), + ); + const grok = catalog.models.find((model) => model.id === "xai/grok-4.6"); + assert.equal(grok.api.urlValid, true); + assert.equal(grok.pricing.class, "paid"); + assert.equal(grok.pricing.reasons.includes("identity-conflict"), false); + const restored = validateCatalog(JSON.parse(JSON.stringify(catalog))); + assert.equal( + restored.models.find((model) => model.id === "xai/grok-4.6").api.urlValid, + true, + ); +}); + +test("custom gateway cannot inherit unspecified public rates or override mismatch with CLI cost", () => { + const observedAt = "2026-09-08T12:00:00.000Z"; + const parsed = parseOpenCodeVerboseCatalog( + `xai/grok-4.6 +{ + "name": "Grok 4.6", + "status": "active", + "api": { "id": "grok-4.6", "npm": "@ai-sdk/xai", "url": "https://gateway.example/v1" }, + "cost": {"input": 3, "output": 15}, + "limit": {"context": 200000}, + "capabilities": {"toolcall": true, "input": {"text": true}, "output": {"text": true}} +}`, + { observedAt }, + ); + const publicMetadata = normalizeModelsDev( + { + xai: { + id: "xai", + npm: "@ai-sdk/xai", + models: { "grok-4.6": { id: "grok-4.6", cost: { input: 3, output: 15 } } }, + }, + }, + { fetchedAt: observedAt }, + ); + const catalog = validateCatalog( + mergeDiscoveredCatalog(loadModelCatalog(), parsed, { + publicMetadata, + now: Date.parse(observedAt), + }), + ); + const grok = catalog.models.find((model) => model.id === "xai/grok-4.6"); + assert.equal(grok.api.url, "https://gateway.example/v1"); + assert.equal(grok.api.urlValid, true); + assert.equal(classifyModelPricing(grok, { now: Date.parse(observedAt) }), "unknown"); + assert.notEqual(grok.pricing.source, "reported-paid"); +}); + +test("cached invalid endpoint provenance stays invalid until a successful fresh unspecified discovery", () => { + const observedAt = "2026-09-08T12:00:00.000Z"; + const invalid = parseOpenCodeVerboseCatalog( + `vendor/nested/spark +{ + "name": "Spark", + "status": "active", + "api": { "id": "nested/spark", "npm": "sdk", "url": "not a url" }, + "cost": {"input": 0, "output": 0}, + "limit": {"context": 100000}, + "capabilities": {"toolcall": true, "input": {"text": true}, "output": {"text": true}} +}`, + { observedAt }, + ); + assert.equal(invalid[0].api.urlValid, false); + const publicMetadata = normalizeModelsDev( + { + vendor: { + id: "vendor", + npm: "sdk", + models: { "nested/spark": { id: "nested/spark", cost: { input: 0, output: 0 } } }, + }, + }, + { fetchedAt: observedAt }, + ); + const blocked = validateCatalog( + mergeDiscoveredCatalog(loadModelCatalog(), invalid, { + publicMetadata, + now: Date.parse(observedAt), + }), + ); + const blockedModel = blocked.models.find((model) => model.id === "vendor/nested/spark"); + assert.equal(blockedModel.api.urlValid, false); + const restored = validateCatalog(JSON.parse(JSON.stringify(blocked))); + assert.equal( + restored.models.find((model) => model.id === "vendor/nested/spark").api.urlValid, + false, + ); + const recovered = parseOpenCodeVerboseCatalog( + `vendor/nested/spark +{ + "name": "Spark", + "status": "active", + "api": { "id": "nested/spark", "npm": "sdk", "url": "" }, + "cost": {"input": 0, "output": 0}, + "limit": {"context": 100000}, + "capabilities": {"toolcall": true, "input": {"text": true}, "output": {"text": true}} +}`, + { observedAt }, + ); + const fresh = validateCatalog( + mergeDiscoveredCatalog(restored, recovered, { + publicMetadata, + now: Date.parse(observedAt), + }), + ); + const spark = fresh.models.find((model) => model.id === "vendor/nested/spark"); + assert.equal(spark.api.urlValid, true); + assert.equal(classifyModelPricing(spark, { now: Date.parse(observedAt) }), "free"); +}); + test("parsed known-paid provider metadata reaches validated settings and generated config", () => { const parsed = parseOpenCodeVerboseCatalog(`opencode/big-pickle { From ef07545b5e0e0734c9bba9af6c15cc5abd65954f Mon Sep 17 00:00:00 2001 From: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:55:56 -0400 Subject: [PATCH 02/15] feat: add connection snapshots and v4 paidEligibility migration Persist nonsecret connection identities per configured host slot. Existing Paid settings migrate to verified-pricing so unpriced routes are not newly authorized without an explicit adoption. Signed-off-by: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> --- .../schemas/connection-store.schema.json | 204 ++++++++++++++++ .../schemas/router-settings.schema.json | 102 +++++++- src/core/connections.js | 228 ++++++++++++++++++ src/core/constants.js | 7 +- src/core/index.js | 1 + src/core/settings.js | 97 +++++++- src/opencode/connection-observer.js | 77 ++++++ src/server/connection-store.js | 116 +++++++++ src/server/service.js | 2 +- src/server/state-snapshot.js | 7 + src/ui/model-control.js | 26 +- src/ui/types.ts | 8 + test/core/connections.test.js | 171 +++++++++++++ test/core/schemas.test.js | 3 +- test/core/settings-v3.test.js | 3 +- test/core/settings-v4.test.js | 117 +++++++++ test/core/settings.test.js | 15 +- test/server/connection-store.test.js | 51 ++++ test/server/state-store-v3.test.js | 2 +- test/ui/model-control.test.js | 5 +- test/ui/policy-v3.test.js | 2 +- 21 files changed, 1228 insertions(+), 16 deletions(-) create mode 100644 benchmarks/schemas/connection-store.schema.json create mode 100644 src/core/connections.js create mode 100644 src/opencode/connection-observer.js create mode 100644 src/server/connection-store.js create mode 100644 test/core/connections.test.js create mode 100644 test/core/settings-v4.test.js create mode 100644 test/server/connection-store.test.js diff --git a/benchmarks/schemas/connection-store.schema.json b/benchmarks/schemas/connection-store.schema.json new file mode 100644 index 0000000..a2ebe3a --- /dev/null +++ b/benchmarks/schemas/connection-store.schema.json @@ -0,0 +1,204 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencode-model-control.local/schemas/connection-store.schema.json", + "title": "OpenCode Model Control Connection Snapshot", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "revision", + "scopeId", + "connections" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "scopeId": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "Private installation scope UUID. Not a path, account name, or credential fingerprint." + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/$defs/connection" + } + } + }, + "$defs": { + "hex32": { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "quota": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "unit", + "limit", + "used", + "remaining", + "resetsAt", + "observedAt", + "expiresAt" + ], + "properties": { + "source": { + "enum": [ + "host", + "provider-adapter" + ] + }, + "unit": { + "enum": [ + "tokens", + "requests", + "credits", + "percent" + ] + }, + "limit": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "used": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "remaining": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "resetsAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "observedAt": { + "$ref": "#/$defs/timestamp" + }, + "expiresAt": { + "$ref": "#/$defs/timestamp" + } + } + }, + "connection": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "providerId", + "bindingRevision", + "authKind", + "billing", + "transportVisibility", + "inventoryObservedAt", + "entitlement", + "quota" + ], + "properties": { + "id": { + "$ref": "#/$defs/hex32" + }, + "providerId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "bindingRevision": { + "$ref": "#/$defs/hex32" + }, + "authKind": { + "enum": [ + "oauth", + "api-key", + "none", + "unknown" + ] + }, + "billing": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "source", + "observedAt" + ], + "properties": { + "kind": { + "enum": [ + "subscription", + "metered-api", + "prepaid", + "local", + "free", + "unknown" + ] + }, + "source": { + "enum": [ + "host", + "provider-adapter", + "user-declared", + "unknown" + ] + }, + "observedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "transportVisibility": { + "enum": [ + "declared-endpoint", + "host-managed" + ] + }, + "inventoryObservedAt": { + "$ref": "#/$defs/timestamp" + }, + "entitlement": { + "enum": [ + "reported-active", + "reported-revoked", + "not-reported" + ] + }, + "quota": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/quota" + } + ] + } + } + } + } +} diff --git a/benchmarks/schemas/router-settings.schema.json b/benchmarks/schemas/router-settings.schema.json index 1be12ce..498b1c7 100644 --- a/benchmarks/schemas/router-settings.schema.json +++ b/benchmarks/schemas/router-settings.schema.json @@ -8,7 +8,10 @@ "schemaVersion", "costPreference", "costPolicy", + "paidEligibility", "roleAssignments", + "roleConnections", + "billingDeclarations", "maxDelegationDepth", "maxFallbacksPerAssignment", "makeRouterDefault", @@ -17,7 +20,7 @@ ], "properties": { "schemaVersion": { - "const": 3 + "const": 4 }, "costPreference": { "enum": [ @@ -31,6 +34,46 @@ "known-cost" ] }, + "paidEligibility": { + "enum": [ + "verified-pricing", + "configured-connections" + ] + }, + "roleConnections": { + "type": "object", + "additionalProperties": false, + "required": [ + "orchestrator", + "code-worker", + "vision-worker", + "reviewer" + ], + "properties": { + "orchestrator": { + "$ref": "#/$defs/roleConnection" + }, + "code-worker": { + "$ref": "#/$defs/roleConnection" + }, + "vision-worker": { + "$ref": "#/$defs/roleConnection" + }, + "reviewer": { + "$ref": "#/$defs/roleConnection" + } + } + }, + "billingDeclarations": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + "additionalProperties": { + "$ref": "#/$defs/billingDeclaration" + } + }, "roleAssignments": { "type": "object", "additionalProperties": false, @@ -84,6 +127,63 @@ } }, "$defs": { + "roleConnection": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "connectionId", + "bindingRevision" + ], + "properties": { + "connectionId": { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + "bindingRevision": { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + } + } + } + ] + }, + "billingDeclaration": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "bindingRevision", + "source" + ], + "properties": { + "kind": { + "enum": [ + "subscription", + "metered-api", + "prepaid", + "local", + "free", + "unknown" + ] + }, + "bindingRevision": { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + "source": { + "const": "user-declared" + }, + "declaredAt": { + "type": "string", + "format": "date-time" + } + } + }, "assignment": { "oneOf": [ { diff --git a/src/core/connections.js b/src/core/connections.js new file mode 100644 index 0000000..e7e03ad --- /dev/null +++ b/src/core/connections.js @@ -0,0 +1,228 @@ +import { createHash } from "node:crypto"; +import { routerError } from "./errors.js"; +import { isPlainObject } from "./utils.js"; +import { normalizeApiIdentity } from "./pricing.js"; + +export const CURRENT_CONNECTION_STORE_VERSION = 1; +export const BILLING_KINDS = Object.freeze([ + "subscription", + "metered-api", + "prepaid", + "local", + "free", + "unknown", +]); +export const EVIDENCE_SOURCES = Object.freeze([ + "host", + "provider-adapter", + "user-declared", + "unknown", +]); +export const AUTH_KINDS = Object.freeze(["oauth", "api-key", "none", "unknown"]); +export const ENTITLEMENTS = Object.freeze([ + "reported-active", + "reported-revoked", + "not-reported", +]); +export const TRANSPORT_VISIBILITY = Object.freeze([ + "declared-endpoint", + "host-managed", +]); +export const QUOTA_UNITS = Object.freeze([ + "tokens", + "requests", + "credits", + "percent", +]); +export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const HEX32_PATTERN = /^[a-f0-9]{32}$/; +const TIMESTAMP = (value) => + typeof value === "string" && Number.isFinite(Date.parse(value)); + +function invalidConnection(message) { + throw routerError("INVALID_CONNECTION", message); +} + +export function deriveConnectionId(scopeId, providerId) { + if (typeof scopeId !== "string" || !UUID_PATTERN.test(scopeId)) + invalidConnection("Connection scope is invalid."); + if (typeof providerId !== "string" || !PROVIDER_ID_PATTERN.test(providerId)) + invalidConnection("Connection provider is invalid."); + return createHash("sha256") + .update(`omc.connection.v1\0${scopeId}\0${providerId}`) + .digest("hex") + .slice(0, 32); +} + +export function deriveBindingRevision(input) { + return createHash("sha256") + .update( + JSON.stringify({ + url: input?.url ?? null, + npm: input?.npm ?? null, + authKind: input?.authKind ?? "unknown", + billingKind: input?.billingKind ?? "unknown", + mixed: input?.mixed === true, + }), + ) + .digest("hex") + .slice(0, 32); +} + +function finiteNonNegative(value) { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +export function validateQuotaObservation(value) { + if (value === null || value === undefined) return null; + if (!isPlainObject(value)) invalidConnection("Quota observation is invalid."); + const unit = value.unit; + if (!QUOTA_UNITS.includes(unit)) invalidConnection("Quota unit is invalid."); + const numeric = ["limit", "used", "remaining"]; + const quota = { + source: + value.source === "host" || value.source === "provider-adapter" + ? value.source + : invalidConnection("Quota source is invalid."), + unit, + limit: value.limit === null ? null : value.limit, + used: value.used === null ? null : value.used, + remaining: value.remaining === null ? null : value.remaining, + resetsAt: value.resetsAt === null ? null : value.resetsAt, + observedAt: value.observedAt, + expiresAt: value.expiresAt, + }; + for (const key of numeric) { + if (quota[key] !== null && !finiteNonNegative(quota[key])) + invalidConnection("Quota values must be finite and nonnegative."); + } + if ( + unit === "percent" && + numeric.some((key) => quota[key] !== null && quota[key] > 100) + ) + invalidConnection("Quota percent values must be within 0-100."); + if (!TIMESTAMP(quota.observedAt) || !TIMESTAMP(quota.expiresAt)) + invalidConnection("Quota timestamps are invalid."); + if (!["host", "provider-adapter"].includes(quota.source)) + invalidConnection("Quota source is invalid."); + return quota; +} + +export function validateConnection(value) { + if (!isPlainObject(value)) invalidConnection("Connection is invalid."); + const providerId = value.providerId; + if (typeof providerId !== "string" || !PROVIDER_ID_PATTERN.test(providerId)) + invalidConnection("Connection provider is invalid."); + if (typeof value.id !== "string" || !HEX32_PATTERN.test(value.id)) + invalidConnection("Connection identity is invalid."); + if ( + typeof value.bindingRevision !== "string" || + !HEX32_PATTERN.test(value.bindingRevision) + ) + invalidConnection("Connection binding is invalid."); + if (!AUTH_KINDS.includes(value.authKind)) + invalidConnection("Connection authentication is invalid."); + if (!isPlainObject(value.billing)) + invalidConnection("Connection billing is invalid."); + if (!BILLING_KINDS.includes(value.billing.kind)) + invalidConnection("Connection billing is invalid."); + if (!EVIDENCE_SOURCES.includes(value.billing.source)) + invalidConnection("Connection billing is invalid."); + if (value.billing.observedAt !== null && !TIMESTAMP(value.billing.observedAt)) + invalidConnection("Connection billing is invalid."); + if (!TRANSPORT_VISIBILITY.includes(value.transportVisibility)) + invalidConnection("Connection transport is invalid."); + if (!TIMESTAMP(value.inventoryObservedAt)) + invalidConnection("Connection inventory time is invalid."); + if (!ENTITLEMENTS.includes(value.entitlement)) + invalidConnection("Connection entitlement is invalid."); + return { + id: value.id, + providerId, + bindingRevision: value.bindingRevision, + authKind: value.authKind, + billing: { + kind: value.billing.kind, + source: value.billing.source, + observedAt: value.billing.observedAt, + }, + transportVisibility: value.transportVisibility, + inventoryObservedAt: value.inventoryObservedAt, + entitlement: value.entitlement, + quota: validateQuotaObservation(value.quota ?? null), + }; +} + +export function validateConnectionSnapshot(value) { + if (!isPlainObject(value)) invalidConnection("Connection snapshot is invalid."); + if (value.schemaVersion !== CURRENT_CONNECTION_STORE_VERSION) + invalidConnection("Connection snapshot version is unsupported."); + if (typeof value.scopeId !== "string" || !UUID_PATTERN.test(value.scopeId)) + invalidConnection("Connection scope is invalid."); + if (!Array.isArray(value.connections)) + invalidConnection("Connection snapshot is invalid."); + const connections = value.connections.map(validateConnection); + const ids = new Set(); + const providers = new Set(); + for (const connection of connections) { + if (ids.has(connection.id) || providers.has(connection.providerId)) + invalidConnection("Connection identities must be unique."); + ids.add(connection.id); + providers.add(connection.providerId); + if (connection.id !== deriveConnectionId(value.scopeId, connection.providerId)) + invalidConnection("Connection identity is invalid."); + } + const revision = + typeof value.revision === "string" && /^[a-f0-9]{64}$/.test(value.revision) + ? value.revision + : createHash("sha256") + .update(JSON.stringify(connections)) + .digest("hex"); + return { + schemaVersion: CURRENT_CONNECTION_STORE_VERSION, + revision, + scopeId: value.scopeId, + connections, + }; +} + +export function applyBillingDeclarations(connections, declarations = {}) { + return connections.map((connection) => { + const declaration = declarations[connection.id]; + if (!declaration) return connection; + if (declaration.bindingRevision !== connection.bindingRevision) { + return { + ...connection, + billing: { kind: "unknown", source: "unknown", observedAt: null }, + }; + } + return { + ...connection, + billing: { + kind: declaration.kind, + source: "user-declared", + observedAt: declaration.declaredAt ?? connection.inventoryObservedAt, + }, + }; + }); +} + +export function connectionBindingInputs(provider) { + const models = Object.values(provider?.models ?? {}); + const identities = models.map((model) => normalizeApiIdentity(model?.api)); + const npms = [ + ...new Set(identities.map((api) => api.npm).filter((value) => value)), + ].sort(); + const urls = [ + ...new Set(identities.map((api) => api.url).filter((value) => value !== undefined)), + ].sort((left, right) => String(left) < String(right) ? -1 : 1); + const mixed = npms.length > 1 || new Set(urls.map((value) => value ?? "")).size > 1; + return { + npm: npms.length === 1 ? npms[0] : null, + url: !mixed && urls.length <= 1 ? (urls[0] ?? null) : null, + mixed, + declared: identities.some((api) => api.urlValid && api.url !== null), + }; +} diff --git a/src/core/constants.js b/src/core/constants.js index 934a6cf..9cc307d 100644 --- a/src/core/constants.js +++ b/src/core/constants.js @@ -33,6 +33,10 @@ export const MODALITIES = Object.freeze([ export const COST_PREFERENCES = Object.freeze(["free-first", "paid-first"]); export const COST_POLICIES = Object.freeze(["free-only", "known-cost"]); +export const PAID_ELIGIBILITY = Object.freeze([ + "verified-pricing", + "configured-connections", +]); export const PRICING_CLASSES = Object.freeze(["free", "paid", "unknown"]); export const KNOWN_MODEL_IDS = Object.freeze([ @@ -55,8 +59,9 @@ export const ROLE_REQUIREMENTS = Object.freeze({ reviewer: Object.freeze({ modalities: Object.freeze(["text"]), access: "read" }), }); -export const CURRENT_SETTINGS_VERSION = 3; +export const CURRENT_SETTINGS_VERSION = 4; export const CURRENT_CATALOG_VERSION = 2; +export const CURRENT_CONNECTION_STORE_VERSION = 1; export const CURRENT_PLAN_VERSION = 1; export const CURRENT_RESULT_VERSION = 1; export const AUTO_ASSIGNMENT = "auto"; diff --git a/src/core/index.js b/src/core/index.js index cff8c32..376ad0a 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -4,6 +4,7 @@ export { COMPLEXITIES, COST_POLICIES, COST_PREFERENCES, + PAID_ELIGIBILITY, KNOWN_MODEL_IDS, MODEL_ROLES, MODALITIES, diff --git a/src/core/settings.js b/src/core/settings.js index 0c68bc2..ce4d3bd 100644 --- a/src/core/settings.js +++ b/src/core/settings.js @@ -4,6 +4,7 @@ import { COST_PREFERENCES, CURRENT_SETTINGS_VERSION, MODEL_ROLES, + PAID_ELIGIBILITY, ROLE_REQUIREMENTS, } from "./constants.js"; import { @@ -87,6 +88,74 @@ function defaultRoleAssignmentsForCatalog(catalog) { ); } +function emptyRoleConnections() { + return Object.fromEntries(MODEL_ROLES.map((role) => [role, null])); +} + +function normalizeRoleConnections(value) { + if (value !== undefined && !isPlainObject(value)) + invalidSettings("roleConnections must be an object."); + const supplied = value ?? {}; + for (const role of Object.keys(supplied)) { + if (!MODEL_ROLES.includes(role)) + invalidSettings("Settings contain an unknown role."); + } + return Object.fromEntries( + MODEL_ROLES.map((role) => { + const binding = Object.hasOwn(supplied, role) ? supplied[role] : null; + if (binding === null || binding === undefined) return [role, null]; + if ( + !isPlainObject(binding) || + typeof binding.connectionId !== "string" || + !/^[a-f0-9]{32}$/.test(binding.connectionId) || + typeof binding.bindingRevision !== "string" || + !/^[a-f0-9]{32}$/.test(binding.bindingRevision) + ) + invalidSettings(`Role connection for ${role} is invalid.`); + return [ + role, + { + connectionId: binding.connectionId, + bindingRevision: binding.bindingRevision, + }, + ]; + }), + ); +} + +function normalizeBillingDeclarations(value) { + if (value === undefined || value === null) return {}; + if (!isPlainObject(value)) + invalidSettings("billingDeclarations must be an object."); + return Object.fromEntries( + Object.entries(value).map(([connectionId, declaration]) => { + if (!/^[a-f0-9]{32}$/.test(connectionId)) + invalidSettings("Billing declaration identity is invalid."); + if ( + !isPlainObject(declaration) || + !["subscription", "metered-api", "prepaid", "local", "free", "unknown"].includes( + declaration.kind, + ) || + typeof declaration.bindingRevision !== "string" || + !/^[a-f0-9]{32}$/.test(declaration.bindingRevision) || + (declaration.declaredAt !== undefined && + (typeof declaration.declaredAt !== "string" || + !Number.isFinite(Date.parse(declaration.declaredAt)))) + ) + invalidSettings("Billing declaration is invalid."); + return [ + connectionId, + { + kind: declaration.kind, + bindingRevision: declaration.bindingRevision, + source: "user-declared", + ...(declaration.declaredAt ? { declaredAt: declaration.declaredAt } : {}), + }, + ]; + }), + ); +} + function normalizeRoleAssignments(value, catalog) { if (value !== undefined && !isPlainObject(value)) { invalidSettings("roleAssignments must be an object."); @@ -159,6 +228,9 @@ export function validateSettings(value, catalog = loadModelCatalog()) { if (!COST_POLICIES.includes(value.costPolicy)) { invalidSettings("costPolicy is unsupported."); } + if (!PAID_ELIGIBILITY.includes(value.paidEligibility)) { + invalidSettings("paidEligibility is unsupported."); + } if ( !Number.isInteger(value.maxDelegationDepth) || value.maxDelegationDepth < 0 || @@ -190,10 +262,13 @@ export function validateSettings(value, catalog = loadModelCatalog()) { autoIncludeNewModels: value.autoIncludeNewModels ?? true, costPreference: value.costPreference, costPolicy: value.costPolicy, + paidEligibility: value.paidEligibility, roleAssignments: normalizeRoleAssignments( value.roleAssignments, normalizedCatalog, ), + roleConnections: normalizeRoleConnections(value.roleConnections), + billingDeclarations: normalizeBillingDeclarations(value.billingDeclarations), maxDelegationDepth: value.maxDelegationDepth, maxFallbacksPerAssignment: value.maxFallbacksPerAssignment, makeRouterDefault: value.makeRouterDefault ?? true, @@ -211,7 +286,10 @@ export function createDefaultSettings(catalog = loadModelCatalog()) { schemaVersion: CURRENT_SETTINGS_VERSION, costPreference: DEFAULT_COST_PREFERENCE, costPolicy: DEFAULT_COST_POLICY, + paidEligibility: "verified-pricing", roleAssignments: defaultRoleAssignmentsForCatalog(normalizedCatalog), + roleConnections: emptyRoleConnections(), + billingDeclarations: {}, maxDelegationDepth: 1, maxFallbacksPerAssignment: 1, makeRouterDefault: true, @@ -281,6 +359,9 @@ export function migrateSettings(value, catalog = loadModelCatalog()) { if (value.schemaVersion === CURRENT_SETTINGS_VERSION) { return validateSettings(value, normalizedCatalog); } + if (value.schemaVersion === 3) { + return validateSettings(upgradeV3ToV4(value), normalizedCatalog); + } if ( value.schemaVersion !== undefined && value.schemaVersion !== 0 && @@ -319,8 +400,8 @@ export function migrateSettings(value, catalog = loadModelCatalog()) { controls[id] = { ...controls[id], available: false }; return validateSettings( - { - schemaVersion: CURRENT_SETTINGS_VERSION, + upgradeV3ToV4({ + schemaVersion: 3, costPreference: value.costPreference ?? DEFAULT_COST_PREFERENCE, costPolicy: value.costPolicy ?? DEFAULT_COST_POLICY, autoIncludeNewModels: true, @@ -338,11 +419,21 @@ export function migrateSettings(value, catalog = loadModelCatalog()) { ? value.makeRouterDefault : true, modelControls: controls, - }, + }), normalizedCatalog, ); } +function upgradeV3ToV4(value) { + return { + ...value, + schemaVersion: CURRENT_SETTINGS_VERSION, + paidEligibility: "verified-pricing", + roleConnections: emptyRoleConnections(), + billingDeclarations: {}, + }; +} + export const DEFAULT_SETTINGS = deepFreeze(createDefaultSettings()); export function cloneDefaultSettings() { diff --git a/src/opencode/connection-observer.js b/src/opencode/connection-observer.js new file mode 100644 index 0000000..bfc85d9 --- /dev/null +++ b/src/opencode/connection-observer.js @@ -0,0 +1,77 @@ +import { + PROVIDER_ID_PATTERN, + deriveBindingRevision, + deriveConnectionId, + validateConnection, + connectionBindingInputs, +} from "../core/connections.js"; +import { isPlainObject } from "../core/utils.js"; + +function iso(now) { + return new Date(now).toISOString(); +} + +function previousByProvider(previousConnections) { + return new Map( + (previousConnections ?? []).map((connection) => [ + connection.providerId, + connection, + ]), + ); +} + +export function observeConnections({ + providers, + previousConnections = [], + scopeId, + now = Date.now(), +} = {}) { + if (!Array.isArray(providers)) return []; + const previous = previousByProvider(previousConnections); + const observedAt = iso(now); + const connections = []; + const seen = new Set(); + for (const provider of providers) { + if (!isPlainObject(provider) || typeof provider.id !== "string") continue; + if (!PROVIDER_ID_PATTERN.test(provider.id)) continue; + if (seen.has(provider.id)) continue; + seen.add(provider.id); + const binding = connectionBindingInputs(provider); + const prior = previous.get(provider.id); + const authKind = "unknown"; + const billingKind = "unknown"; + const bindingRevision = deriveBindingRevision({ + url: binding.url, + npm: binding.npm, + authKind, + billingKind, + mixed: binding.mixed, + }); + const billingUnchanged = + prior && + prior.bindingRevision === bindingRevision && + prior.billing?.source === "user-declared"; + connections.push( + validateConnection({ + id: deriveConnectionId(scopeId, provider.id), + providerId: provider.id, + bindingRevision, + authKind, + billing: billingUnchanged + ? prior.billing + : { + kind: "unknown", + source: "unknown", + observedAt: null, + }, + transportVisibility: binding.declared + ? "declared-endpoint" + : "host-managed", + inventoryObservedAt: observedAt, + entitlement: "not-reported", + quota: null, + }), + ); + } + return connections; +} diff --git a/src/server/connection-store.js b/src/server/connection-store.js new file mode 100644 index 0000000..760ff52 --- /dev/null +++ b/src/server/connection-store.js @@ -0,0 +1,116 @@ +import { randomUUID, createHash } from "node:crypto"; +import { + chmod, + lstat, + mkdir, + readFile, + rename, + unlink, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { + CURRENT_CONNECTION_STORE_VERSION, + validateConnectionSnapshot, +} from "../core/connections.js"; +import { withStateLock } from "./state-lock.js"; + +const MAX_CONNECTION_SNAPSHOT_BYTES = 1024 * 1024; + +export function resolveConnectionSnapshotPath(settingsPath) { + return join(dirname(settingsPath), "connections.json"); +} + +function emptySnapshot() { + const connections = []; + return { + schemaVersion: CURRENT_CONNECTION_STORE_VERSION, + revision: createHash("sha256").update(JSON.stringify(connections)).digest("hex"), + scopeId: randomUUID(), + connections, + }; +} + +export async function readConnectionSnapshot({ + settingsPath, + path = resolveConnectionSnapshotPath(settingsPath), + locked = false, +} = {}) { + const read = async () => { + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw Object.assign(new Error("Connection snapshot must be a regular file."), { + code: "CONNECTION_SNAPSHOT_INVALID", + }); + } + if (metadata.size > MAX_CONNECTION_SNAPSHOT_BYTES) { + throw Object.assign(new Error("Connection snapshot is too large."), { + code: "CONNECTION_SNAPSHOT_TOO_LARGE", + }); + } + return validateConnectionSnapshot(JSON.parse(await readFile(path, "utf8"))); + } catch (error) { + if (error?.code === "ENOENT") { + const snapshot = emptySnapshot(); + await writeConnectionSnapshot({ + settingsPath, + snapshot, + path, + locked: true, + }); + return snapshot; + } + if (error instanceof SyntaxError) { + throw Object.assign(new Error("Connection snapshot is not valid JSON."), { + code: "CONNECTION_SNAPSHOT_INVALID_JSON", + }); + } + throw error; + } + }; + return locked ? read() : withStateLock(settingsPath, read); +} + +export async function writeConnectionSnapshot({ + settingsPath, + snapshot, + path = resolveConnectionSnapshotPath(settingsPath), + locked = false, +} = {}) { + const write = async () => { + const normalized = validateConnectionSnapshot(snapshot); + const payload = `${JSON.stringify(normalized, null, 2)}\n`; + if (Buffer.byteLength(payload) > MAX_CONNECTION_SNAPSHOT_BYTES) { + throw Object.assign(new Error("Connection snapshot is too large."), { + code: "CONNECTION_SNAPSHOT_TOO_LARGE", + }); + } + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + const temporaryPath = join( + directory, + `.connections-${randomUUID()}.tmp`, + ); + try { + await writeFile(temporaryPath, payload, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + await rename(temporaryPath, path); + await chmod(path, 0o600); + } catch (error) { + try { + await unlink(temporaryPath); + } catch { + /* original error is more useful */ + } + throw error; + } + return normalized; + }; + return locked ? write() : withStateLock(settingsPath, write); +} diff --git a/src/server/service.js b/src/server/service.js index 96c7752..69d817a 100644 --- a/src/server/service.js +++ b/src/server/service.js @@ -251,7 +251,7 @@ export class ControlService { getState() { return { - schemaVersion: 3, + schemaVersion: 4, settingsRevision: this.settingsRevision, catalogRevision: this.catalog.revision, blockedRoles: blockedRoles(this.catalog, this.settings), diff --git a/src/server/state-snapshot.js b/src/server/state-snapshot.js index 92ec7bb..49840a2 100644 --- a/src/server/state-snapshot.js +++ b/src/server/state-snapshot.js @@ -11,6 +11,7 @@ import { readCatalogSnapshot, resolveCatalogSnapshotPath, } from "./catalog-store.js"; +import { readConnectionSnapshot } from "./connection-store.js"; import { withStateLock } from "./state-lock.js"; export const refreshStatusPath = (settingsPath) => @@ -79,9 +80,15 @@ export async function readControlSnapshot({ locked: true, }); const raw = (await readRawSettings(settingsPath)).value; + const connections = await readConnectionSnapshot({ + settingsPath, + locked: true, + }); return { catalog, settings, + connections: connections.connections, + connectionRevision: connections.revision, settingsExists: raw !== undefined, catalogExists: savedCatalog !== null, settingsRevision: settingsRevision(raw), diff --git a/src/ui/model-control.js b/src/ui/model-control.js index 1c1506e..8ebf42d 100644 --- a/src/ui/model-control.js +++ b/src/ui/model-control.js @@ -220,8 +220,12 @@ export function normalizeSettings(settings = {}, catalog = []) { return { ...settings, - schemaVersion: 3, + schemaVersion: 4, autoIncludeNewModels: settings.autoIncludeNewModels !== false, + paidEligibility: + settings.paidEligibility === "configured-connections" + ? "configured-connections" + : "verified-pricing", costPreference: settings.costPreference === "paid-first" || settings.freeOnly === false ? "paid-first" @@ -252,6 +256,13 @@ export function normalizeSettings(settings = {}, catalog = []) { typeof settings.makeRouterDefault === "boolean" ? settings.makeRouterDefault : true, + roleConnections: settings.roleConnections ?? { + orchestrator: null, + "code-worker": null, + "vision-worker": null, + reviewer: null, + }, + billingDeclarations: settings.billingDeclarations ?? {}, }; } @@ -281,12 +292,23 @@ export function settingsEqual(left, right) { export function settingsForApi(settings) { return { - schemaVersion: 3, + schemaVersion: 4, autoIncludeNewModels: settings.autoIncludeNewModels !== false, + paidEligibility: + settings.paidEligibility === "configured-connections" + ? "configured-connections" + : "verified-pricing", costPreference: settings.costPreference === "paid-first" ? "paid-first" : "free-first", costPolicy: settings.costPolicy === "known-cost" ? "known-cost" : "free-only", + roleConnections: settings.roleConnections ?? { + orchestrator: null, + "code-worker": null, + "vision-worker": null, + reviewer: null, + }, + billingDeclarations: settings.billingDeclarations ?? {}, maxDelegationDepth: clampInteger(settings.maxDelegationDepth, 1, 0, 1), maxFallbacksPerAssignment: clampInteger( settings.maxFallbacksPerAssignment, diff --git a/src/ui/types.ts b/src/ui/types.ts index d105689..4795bc7 100644 --- a/src/ui/types.ts +++ b/src/ui/types.ts @@ -63,13 +63,21 @@ export interface ModelControl { available?: boolean; } +export interface RoleConnection { + connectionId: string; + bindingRevision: string; +} + export interface RouterSettings { schemaVersion?: number; costPreference: "free-first" | "paid-first"; costPolicy: "free-only" | "known-cost"; + paidEligibility?: "verified-pricing" | "configured-connections"; freeOnly?: boolean; autoIncludeNewModels: boolean; roleAssignments: RoleAssignments; + roleConnections?: Record; + billingDeclarations?: Record; modelControls: Record; maxDelegationDepth: number; maxFallbacksPerAssignment: number; diff --git a/test/core/connections.test.js b/test/core/connections.test.js new file mode 100644 index 0000000..e5ff7f0 --- /dev/null +++ b/test/core/connections.test.js @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + applyBillingDeclarations, + deriveBindingRevision, + deriveConnectionId, + validateConnection, + validateConnectionSnapshot, +} from "../../src/core/connections.js"; +import { observeConnections } from "../../src/opencode/connection-observer.js"; + +const scopeId = "11111111-1111-4111-8111-111111111111"; +const now = Date.parse("2026-09-08T12:00:00.000Z"); + +function provider(id, models) { + return { + id, + models: Object.fromEntries( + Object.entries(models).map(([key, api]) => [ + key, + { id: key, providerID: id, api }, + ]), + ), + }; +} + +test("connection IDs are stable, nonsecret, and derived from scope plus provider slot", () => { + const id = deriveConnectionId(scopeId, "xai"); + assert.match(id, /^[a-f0-9]{32}$/); + assert.equal(deriveConnectionId(scopeId, "xai"), id); + assert.notEqual(deriveConnectionId(scopeId, "openai"), id); + assert.doesNotMatch(id, /xai|home|token|panda/i); + assert.throws( + () => deriveConnectionId("/home/user/.config", "xai"), + (error) => error.code === "INVALID_CONNECTION" && !/home/.test(error.message), + ); +}); + +test("invalid connection payloads throw sanitized errors", () => { + assert.throws( + () => validateConnection({ id: "secret-token-value", providerId: "xai" }), + (error) => + error.code === "INVALID_CONNECTION" && + !/secret-token-value/.test(error.message), + ); +}); + +test("one configured slot is one connection; auth methods do not invent billing", () => { + const connections = observeConnections({ + scopeId, + now, + providers: [ + provider("xai", { + "grok-4.6": { id: "grok-4.6", npm: "@ai-sdk/xai", url: "" }, + }), + { + id: "kimi-for-coding", + models: { + k2: { + id: "k2", + providerID: "kimi-for-coding", + api: { + id: "k2", + npm: "@ai-sdk/openai-compatible", + url: "https://api.kimi.com/coding/v1", + }, + options: { apiKey: "sk-test" }, + }, + }, + }, + provider("unfamiliar", { + "spark-1.3": { + id: "spark-1.3", + npm: "@ai-sdk/openai-compatible", + url: null, + }, + }), + ], + }); + assert.equal(connections.length, 3); + assert.equal(new Set(connections.map((item) => item.providerId)).size, 3); + for (const connection of connections) { + assert.equal(connection.authKind, "unknown"); + assert.equal(connection.billing.kind, "unknown"); + assert.equal(connection.billing.source, "unknown"); + assert.equal(connection.entitlement, "not-reported"); + assert.equal(connection.quota, null); + } + const kimi = connections.find((item) => item.providerId === "kimi-for-coding"); + assert.notEqual(kimi.billing.kind, "metered-api"); + assert.equal(kimi.transportVisibility, "declared-endpoint"); + const xai = connections.find((item) => item.providerId === "xai"); + assert.equal(xai.transportVisibility, "host-managed"); +}); + +test("user billing declarations are labelled and invalidated when the binding changes", () => { + const [xai] = observeConnections({ + scopeId, + now, + providers: [ + provider("xai", { + "grok-4.6": { id: "grok-4.6", npm: "@ai-sdk/xai", url: "" }, + }), + ], + }); + const declared = applyBillingDeclarations([xai], { + [xai.id]: { + kind: "subscription", + bindingRevision: xai.bindingRevision, + declaredAt: "2026-09-08T12:00:00.000Z", + }, + }); + assert.equal(declared[0].billing.kind, "subscription"); + assert.equal(declared[0].billing.source, "user-declared"); + const changed = observeConnections({ + scopeId, + now, + previousConnections: declared, + providers: [ + provider("xai", { + "grok-4.6": { + id: "grok-4.6", + npm: "@ai-sdk/xai", + url: "https://api.x.ai/v1", + }, + }), + ], + }); + assert.equal(changed[0].id, xai.id); + assert.notEqual(changed[0].bindingRevision, xai.bindingRevision); + assert.equal(changed[0].billing.kind, "unknown"); + assert.equal(changed[0].billing.source, "unknown"); +}); + +test("quota observations reject invalid numbers and mixed provenance is not invented", () => { + const connection = { + id: deriveConnectionId(scopeId, "openai"), + providerId: "openai", + bindingRevision: deriveBindingRevision({ npm: "@ai-sdk/openai" }), + authKind: "unknown", + billing: { kind: "unknown", source: "unknown", observedAt: null }, + transportVisibility: "host-managed", + inventoryObservedAt: "2026-09-08T12:00:00.000Z", + entitlement: "not-reported", + quota: { + source: "host", + unit: "percent", + limit: 101, + used: 0, + remaining: 0, + resetsAt: null, + observedAt: "2026-09-08T12:00:00.000Z", + expiresAt: "2026-09-09T12:00:00.000Z", + }, + }; + assert.throws( + () => validateConnection(connection), + (error) => error.code === "INVALID_CONNECTION", + ); + const snapshot = validateConnectionSnapshot({ + schemaVersion: 1, + scopeId, + connections: [ + { + ...connection, + quota: null, + }, + ], + }); + assert.equal(snapshot.connections[0].quota, null); +}); diff --git a/test/core/schemas.test.js b/test/core/schemas.test.js index 4865fef..ddffc9b 100644 --- a/test/core/schemas.test.js +++ b/test/core/schemas.test.js @@ -5,6 +5,7 @@ import test from "node:test"; const SCHEMA_FILES = [ "model-catalog.schema.json", "router-settings.schema.json", + "connection-store.schema.json", "route-plan.schema.json", "sanitized-result.schema.json", "routing-cases.schema.json", @@ -18,7 +19,7 @@ function readJson(relativePath) { test("benchmark contracts are strict versioned JSON Schemas", () => { const expectedVersions = new Map([ - ["router-settings.schema.json", 3], + ["router-settings.schema.json", 4], ["model-catalog.schema.json", 2], ]); for (const file of SCHEMA_FILES) { diff --git a/test/core/settings-v3.test.js b/test/core/settings-v3.test.js index 9c6e6c3..ed474db 100644 --- a/test/core/settings-v3.test.js +++ b/test/core/settings-v3.test.js @@ -25,7 +25,8 @@ test("v2 migration keeps explicit choices, paid preference and unavailable pins" }, }; const next = migrateSettings(old, catalog); - assert.equal(next.schemaVersion, 3); + assert.equal(next.schemaVersion, 4); + assert.equal(next.paidEligibility, "verified-pricing"); assert.equal(next.autoIncludeNewModels, true); assert.equal(next.costPreference, "paid-first"); assert.equal(next.roleAssignments.orchestrator, "missing/model"); diff --git a/test/core/settings-v4.test.js b/test/core/settings-v4.test.js new file mode 100644 index 0000000..ed6d23f --- /dev/null +++ b/test/core/settings-v4.test.js @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { loadModelCatalog } from "../fixtures/catalog.js"; +import { + createDefaultSettings, + migrateSettings, + validateSettings, +} from "../../src/core/index.js"; + +test("v3 free and paid settings migrate to verified-pricing without expanding access", () => { + const catalog = loadModelCatalog(); + for (const costPolicy of ["free-only", "known-cost"]) { + const old = { + ...createDefaultSettings(catalog), + schemaVersion: 3, + costPolicy, + autoIncludeNewModels: false, + modelControls: { + "opencode/big-pickle": { selection: "disabled" }, + "missing/model": { selection: "disabled", available: false }, + }, + roleAssignments: { + orchestrator: "opencode/big-pickle", + "code-worker": "missing/model", + "vision-worker": "auto", + reviewer: "auto", + }, + }; + delete old.paidEligibility; + delete old.roleConnections; + delete old.billingDeclarations; + const next = migrateSettings(old, catalog); + assert.equal(next.schemaVersion, 4); + assert.equal(next.paidEligibility, "verified-pricing"); + assert.equal(next.costPolicy, costPolicy); + assert.equal(next.autoIncludeNewModels, false); + assert.equal(next.roleAssignments.orchestrator, "opencode/big-pickle"); + assert.equal(next.roleAssignments["code-worker"], "missing/model"); + assert.equal(next.modelControls["opencode/big-pickle"].selection, "disabled"); + assert.deepEqual(next.roleConnections, { + orchestrator: null, + "code-worker": null, + "vision-worker": null, + reviewer: null, + }); + } +}); + +test("fresh installs stay free and require an explicit configured-connections adoption", () => { + const catalog = loadModelCatalog(); + const settings = createDefaultSettings(catalog); + assert.equal(settings.costPolicy, "free-only"); + assert.equal(settings.paidEligibility, "verified-pricing"); + const adopted = validateSettings( + { + ...settings, + costPolicy: "known-cost", + paidEligibility: "configured-connections", + }, + catalog, + ); + assert.equal(adopted.paidEligibility, "configured-connections"); + assert.equal(adopted.costPolicy, "known-cost"); +}); + +test("older supported settings versions keep disabled models and pins through v4", () => { + const catalog = loadModelCatalog(); + const v3 = { + ...createDefaultSettings(catalog), + schemaVersion: 3, + costPolicy: "known-cost", + modelControls: { + "opencode/big-pickle": { selection: "disabled" }, + }, + roleAssignments: { + ...createDefaultSettings(catalog).roleAssignments, + orchestrator: "opencode/big-pickle", + }, + }; + delete v3.paidEligibility; + delete v3.roleConnections; + delete v3.billingDeclarations; + for (const value of [ + { + schemaVersion: 0, + primary: "opencode/big-pickle", + enabledModels: ["opencode/big-pickle"], + allowPaid: false, + }, + { + schemaVersion: 1, + freeOnly: true, + roleAssignments: { orchestrator: "opencode/big-pickle" }, + }, + { + schemaVersion: 2, + costPreference: "free-first", + costPolicy: "known-cost", + roleAssignments: { + orchestrator: "opencode/big-pickle", + "code-worker": "auto", + "vision-worker": "auto", + reviewer: "auto", + }, + modelControls: { "opencode/big-pickle": { enabled: false } }, + maxDelegationDepth: 1, + maxFallbacksPerAssignment: 1, + makeRouterDefault: true, + }, + v3, + ]) { + const migrated = migrateSettings(value, catalog); + assert.equal(migrated.schemaVersion, 4, String(value.schemaVersion)); + assert.equal(migrated.paidEligibility, "verified-pricing"); + assert.equal(migrated.roleAssignments.orchestrator, "opencode/big-pickle"); + } +}); diff --git a/test/core/settings.test.js b/test/core/settings.test.js index 444210b..27fcf19 100644 --- a/test/core/settings.test.js +++ b/test/core/settings.test.js @@ -16,7 +16,14 @@ test("default settings are strict, bounded, complete, and deterministic", () => assert.deepEqual(settings, createDefaultSettings(catalog)); assert.equal(DEFAULT_SETTINGS.roleAssignments.orchestrator, "auto"); - assert.equal(settings.schemaVersion, 3); + assert.equal(settings.schemaVersion, 4); + assert.equal(settings.paidEligibility, "verified-pricing"); + assert.deepEqual(settings.roleConnections, { + orchestrator: null, + "code-worker": null, + "vision-worker": null, + reviewer: null, + }); assert.equal(settings.costPreference, "free-first"); assert.equal(settings.costPolicy, "free-only"); assert.equal(Object.hasOwn(settings, "freeOnly"), false); @@ -51,7 +58,8 @@ test("legacy settings migrate to schema v3 without enabling unselected models", catalog, ); - assert.equal(migrated.schemaVersion, 3); + assert.equal(migrated.schemaVersion, 4); + assert.equal(migrated.paidEligibility, "verified-pricing"); assert.equal(migrated.costPreference, "free-first"); assert.equal(migrated.costPolicy, "free-only"); assert.equal( @@ -83,7 +91,8 @@ test("schema v1 free-only settings migrate to explicit v3 cost controls", () => delete legacy.costPolicy; const migrated = migrateSettings(legacy, catalog); - assert.equal(migrated.schemaVersion, 3); + assert.equal(migrated.schemaVersion, 4); + assert.equal(migrated.paidEligibility, "verified-pricing"); assert.equal(migrated.costPreference, "free-first"); assert.equal(migrated.costPolicy, "free-only"); }); diff --git a/test/server/connection-store.test.js b/test/server/connection-store.test.js new file mode 100644 index 0000000..fa618b1 --- /dev/null +++ b/test/server/connection-store.test.js @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + readConnectionSnapshot, + writeConnectionSnapshot, +} from "../../src/server/connection-store.js"; +import { deriveConnectionId } from "../../src/core/connections.js"; +import { observeConnections } from "../../src/opencode/connection-observer.js"; + +test("connection snapshots persist atomically with private permissions and stable scope", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "omc-connections-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const settingsPath = join(directory, "settings.json"); + const first = await readConnectionSnapshot({ settingsPath }); + assert.equal(first.schemaVersion, 1); + assert.equal(first.connections.length, 0); + assert.match( + first.scopeId, + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + const observed = observeConnections({ + scopeId: first.scopeId, + now: Date.parse("2026-09-08T12:00:00.000Z"), + providers: [ + { + id: "xai", + models: { + "grok-4.6": { + id: "grok-4.6", + providerID: "xai", + api: { id: "grok-4.6", npm: "@ai-sdk/xai", url: "" }, + }, + }, + }, + ], + }); + const written = await writeConnectionSnapshot({ + settingsPath, + snapshot: { ...first, connections: observed }, + }); + const path = join(directory, "connections.json"); + assert.equal((await stat(path)).mode & 0o777, 0o600); + assert.doesNotMatch(await readFile(path, "utf8"), /token|apiKey|\/home\//); + const reread = await readConnectionSnapshot({ settingsPath }); + assert.equal(reread.scopeId, first.scopeId); + assert.equal(reread.connections[0].id, deriveConnectionId(first.scopeId, "xai")); + assert.equal(reread.revision, written.revision); +}); diff --git a/test/server/state-store-v3.test.js b/test/server/state-store-v3.test.js index 2a1142c..7dbfd6e 100644 --- a/test/server/state-store-v3.test.js +++ b/test/server/state-store-v3.test.js @@ -28,7 +28,7 @@ test("legacy migration is persisted atomically with an exact private backup", as path, migrate: (v) => migrateSettings(v, loadModelCatalog()), }); - assert.equal(JSON.parse(await readFile(path, "utf8")).schemaVersion, 3); + assert.equal(JSON.parse(await readFile(path, "utf8")).schemaVersion, 4); const backups = (await readdir(directory)).filter((name) => name.includes("backup"), ); diff --git a/test/ui/model-control.test.js b/test/ui/model-control.test.js index c224b82..60403e2 100644 --- a/test/ui/model-control.test.js +++ b/test/ui/model-control.test.js @@ -102,16 +102,19 @@ test("serializes the finalized settings contract without UI aliases", () => { assert.deepEqual(Object.keys(payload).sort(), [ "autoIncludeNewModels", + "billingDeclarations", "costPolicy", "costPreference", "makeRouterDefault", "maxDelegationDepth", "maxFallbacksPerAssignment", "modelControls", + "paidEligibility", "roleAssignments", + "roleConnections", "schemaVersion", ]); - assert.equal(payload.schemaVersion, 3); + assert.equal(payload.schemaVersion, 4); assert.equal(payload.costPolicy, "free-only"); assert.equal(payload.makeRouterDefault, true); assert.equal(payload.roleAssignments["vision-worker"], "opencode/mimo-v2.5-free"); diff --git a/test/ui/policy-v3.test.js b/test/ui/policy-v3.test.js index 036acf8..a28a551 100644 --- a/test/ui/policy-v3.test.js +++ b/test/ui/policy-v3.test.js @@ -34,7 +34,7 @@ test("canonical policy leaves new identities absent, preserves explicit pins and ui.modelIntentEnabled({ ...policy, autoIncludeNewModels: true }, model.id), true, ); - assert.equal(ui.settingsForApi(policy).schemaVersion, 3); + assert.equal(ui.settingsForApi(policy).schemaVersion, 4); assert.equal(ui.settingsForApi(policy).autoIncludeNewModels, false); }); test("cost policy never erases paid intent or pins and current expired price blocks enabling", () => { From 55901ca16f93caac8d87be33ae444d10983441c4 Mon Sep 17 00:00:00 2001 From: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:59:22 -0400 Subject: [PATCH 03/15] feat: centralize eligibility for configured-connection paid access Unknown public prices no longer block a configured paid route after the user adopts configured-connections. Free and migrated verified- pricing Paid keep the previous verified-price gate. Signed-off-by: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> --- src/core/catalog.js | 22 +++--- src/core/eligibility.js | 125 ++++++++++++++++++++++++++++++ src/core/index.js | 1 + src/server/service.js | 7 +- test/core/eligibility.test.js | 140 ++++++++++++++++++++++++++++++++++ 5 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 src/core/eligibility.js create mode 100644 test/core/eligibility.test.js diff --git a/src/core/catalog.js b/src/core/catalog.js index dbb8ade..8cf9645 100644 --- a/src/core/catalog.js +++ b/src/core/catalog.js @@ -1,4 +1,5 @@ import { classifyPricingEvidence, unknownPricing, normalizeApiIdentity, capabilityDetails, digestJson } from "./pricing.js"; +import { resolveEligibility } from "./eligibility.js"; import { pricingSchema, capabilityDetailsSchema } from "./catalog-evidence.js"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -378,18 +379,15 @@ export function eligibleModelsForRole({ } return validateCatalog(catalog).models - .filter((model) => { - const control = settings?.modelControls?.[model.id]; - const pricingClass = classifyModelPricing(model); - return ( - pricingClass !== "unknown" && - (costPolicy === "known-cost" || pricingClass === "free") && - model.available === true && - modelEnabled(settings, model.id) && - control?.available !== false && - modelSupports({ model, role, modalities, access }) - ); - }) + .filter((model) => + resolveEligibility({ + model, + settings, + role, + modalities, + access, + }).allowed, + ) .sort((left, right) => compareEligibleModels(left, right, role, costPreference), ); diff --git a/src/core/eligibility.js b/src/core/eligibility.js new file mode 100644 index 0000000..5581d29 --- /dev/null +++ b/src/core/eligibility.js @@ -0,0 +1,125 @@ +import { classifyPricingEvidence } from "./pricing.js"; +import { + COST_POLICIES, + COST_PREFERENCES, + MODEL_ROLES, + PAID_ELIGIBILITY, +} from "./constants.js"; + +function classifyModelPricing(model, options) { + return classifyPricingEvidence(model?.pricing, options); +} + +function modelEnabled(settings, modelId) { + const control = settings?.modelControls?.[modelId]; + const selection = + typeof control?.enabled === "boolean" + ? control.enabled + ? "enabled" + : "disabled" + : (control?.selection ?? "policy"); + return ( + selection === "enabled" || + (selection === "policy" && settings?.autoIncludeNewModels !== false) + ); +} + +function modelSupports({ model, role, modalities, access }) { + const roleScore = model?.roles?.[role]; + if (!MODEL_ROLES.includes(role) || !Number.isInteger(roleScore) || roleScore <= 0) + return false; + if (role === "orchestrator" && model.canOrchestrate !== true) return false; + if ( + (role === "orchestrator" || role === "code-worker" || role === "vision-worker") && + model.toolCall === false + ) + return false; + if (role === "vision-worker" && model.toolCall !== true) return false; + if (!model.access?.includes(access)) return false; + if (!modalities.every((modality) => model.modalities?.input?.includes(modality))) + return false; + return model.modalities?.output?.includes("text") === true; +} + +export function resolveEligibility({ + model, + connection = null, + settings, + role = null, + modalities = ["text"], + access = "read", + hostInventory = null, + now = Date.now(), +} = {}) { + const blockingReasons = []; + const warnings = []; + const pricingStatus = classifyModelPricing(model, { now }); + const costPolicy = settings?.costPolicy; + const paidEligibility = PAID_ELIGIBILITY.includes(settings?.paidEligibility) + ? settings.paidEligibility + : "verified-pricing"; + + if ( + !COST_POLICIES.includes(costPolicy) || + !COST_PREFERENCES.includes(settings?.costPreference) + ) { + blockingReasons.push("invalid-settings"); + } + + if (!modelEnabled(settings, model?.id)) blockingReasons.push("disabled-by-you"); + if ( + model?.available === false || + settings?.modelControls?.[model?.id]?.available === false + ) + blockingReasons.push("host-model-missing"); + + if (model?.api?.urlValid === false) blockingReasons.push("invalid-endpoint"); + + if (connection?.entitlement === "reported-revoked") + blockingReasons.push("entitlement-revoked"); + + const savedBinding = role + ? settings?.roleConnections?.[role] + : null; + if ( + savedBinding && + connection && + (savedBinding.connectionId !== connection.id || + savedBinding.bindingRevision !== connection.bindingRevision) + ) + blockingReasons.push("connection-binding-changed"); + + if (hostInventory && model?.id && !hostInventory.has(model.id)) + blockingReasons.push("host-model-missing"); + + if (costPolicy === "free-only") { + if (pricingStatus !== "free") blockingReasons.push("free-access-unverified"); + if (connection?.billing?.kind === "subscription") + blockingReasons.push("free-access-unverified"); + } else if (paidEligibility === "verified-pricing") { + if (pricingStatus === "unknown") + blockingReasons.push("legacy-paid-pricing-required"); + } else if (pricingStatus === "unknown") { + warnings.push("api-estimate-unavailable"); + if (model?.pricing?.reasons?.includes("public-price-route-mismatch")) + warnings.push("public-price-route-mismatch"); + if (model?.pricing?.reasons?.includes("pricing-expired")) + warnings.push("pricing-expired"); + } + + if ( + role && + model && + !modelSupports({ model, role, modalities, access }) + ) + blockingReasons.push("incompatible-capabilities"); + + return { + allowed: blockingReasons.length === 0, + blockingReasons: [...new Set(blockingReasons)], + warnings: [...new Set(warnings)], + pricingStatus, + connectionId: connection?.id ?? null, + bindingRevision: connection?.bindingRevision ?? null, + }; +} diff --git a/src/core/index.js b/src/core/index.js index 376ad0a..22dc94b 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -32,6 +32,7 @@ export { validateSettings, assertExplicitAssignments, } from "./settings.js"; +export { resolveEligibility } from "./eligibility.js"; export { planRoute } from "./planner.js"; export { sanitizeResult, sanitizeText } from "./sanitize.js"; diff --git a/src/server/service.js b/src/server/service.js index 69d817a..0c836dc 100644 --- a/src/server/service.js +++ b/src/server/service.js @@ -5,6 +5,7 @@ import { modelSupports, modelEnabled, eligibleModelsForRole, + resolveEligibility, assertExplicitAssignments, CATALOG_REFRESH_MS, planRoute, @@ -101,10 +102,8 @@ function modelBlockReasons(model, settings) { if (!modelEnabled(settings, model.id)) reasons.push("disabled"); if (!model.available || settings.modelControls[model.id]?.available === false) reasons.push("unavailable"); - const pricing = classifyModelPricing(model); - if (pricing === "unknown") reasons.push("unknown-pricing"); - else if (pricing === "paid" && settings.costPolicy === "free-only") - reasons.push("paid-blocked"); + const eligibility = resolveEligibility({ model, settings }); + reasons.push(...eligibility.blockingReasons); return reasons; } diff --git a/test/core/eligibility.test.js b/test/core/eligibility.test.js new file mode 100644 index 0000000..56ed25a --- /dev/null +++ b/test/core/eligibility.test.js @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadModelCatalog, syntheticPricing } from "../fixtures/catalog.js"; +import { createDefaultSettings, eligibleModelsForRole } from "../../src/core/index.js"; +import { resolveEligibility } from "../../src/core/eligibility.js"; + +function paidUnknown(catalog, extras = {}) { + const model = { + ...structuredClone(catalog.models[0]), + id: "xai/grok-4.6", + available: true, + api: { + id: "grok-4.6", + npm: "@ai-sdk/xai", + url: null, + urlValid: true, + }, + pricing: { + class: "unknown", + rates: {}, + reasons: ["public-price-route-mismatch"], + source: "https://models.dev/api.json", + digest: "a".repeat(64), + fetchedAt: "2026-09-08T12:00:00.000Z", + expiresAt: "2026-09-09T12:00:00.000Z", + }, + ...extras, + }; + return model; +} + +test("eligibility matrix: free stays verified-free; configured paid allows unknown estimates", () => { + const catalog = loadModelCatalog(); + const free = catalog.models.find((model) => model.id === "opencode/big-pickle"); + const settings = createDefaultSettings(catalog); + assert.equal(resolveEligibility({ model: free, settings }).allowed, true); + + const unknown = paidUnknown(catalog); + assert.equal( + resolveEligibility({ model: unknown, settings }).allowed, + false, + ); + assert.ok( + resolveEligibility({ model: unknown, settings }).blockingReasons.includes( + "free-access-unverified", + ), + ); + + const legacyPaid = { + ...settings, + costPolicy: "known-cost", + paidEligibility: "verified-pricing", + }; + assert.equal(resolveEligibility({ model: unknown, settings: legacyPaid }).allowed, false); + assert.ok( + resolveEligibility({ + model: unknown, + settings: legacyPaid, + }).blockingReasons.includes("legacy-paid-pricing-required"), + ); + + const configured = { + ...settings, + costPolicy: "known-cost", + paidEligibility: "configured-connections", + }; + const allowed = resolveEligibility({ model: unknown, settings: configured }); + assert.equal(allowed.allowed, true); + assert.ok(allowed.warnings.includes("api-estimate-unavailable")); + + const invalid = paidUnknown(catalog, { + api: { id: "grok-4.6", npm: "@ai-sdk/xai", url: null, urlValid: false }, + }); + assert.equal( + resolveEligibility({ model: invalid, settings: configured }).allowed, + false, + ); + assert.ok( + resolveEligibility({ + model: invalid, + settings: configured, + }).blockingReasons.includes("invalid-endpoint"), + ); +}); + +test("disabled models stay blocked; binding changes block pins; disable does not require an estimate", () => { + const catalog = loadModelCatalog(); + const model = catalog.models[0]; + const settings = createDefaultSettings(catalog); + settings.modelControls[model.id] = { selection: "disabled" }; + assert.ok( + resolveEligibility({ model, settings }).blockingReasons.includes("disabled-by-you"), + ); + const configured = { + ...createDefaultSettings(catalog), + costPolicy: "known-cost", + paidEligibility: "configured-connections", + roleConnections: { + orchestrator: { connectionId: "a".repeat(32), bindingRevision: "b".repeat(32) }, + "code-worker": null, + "vision-worker": null, + reviewer: null, + }, + }; + const changed = resolveEligibility({ + model, + settings: configured, + role: "orchestrator", + connection: { + id: "c".repeat(32), + bindingRevision: "d".repeat(32), + entitlement: "not-reported", + }, + }); + assert.ok(changed.blockingReasons.includes("connection-binding-changed")); +}); + +test("configured-connections enrolls unknown-priced models into role eligibility", () => { + const catalog = structuredClone(loadModelCatalog()); + const model = paidUnknown(catalog, { + roles: { "code-worker": 25, orchestrator: 25, reviewer: 25 }, + access: ["read", "write"], + canOrchestrate: true, + toolCall: true, + modalities: { input: ["text"], output: ["text"] }, + }); + catalog.models.push(model); + const settings = createDefaultSettings(catalog); + settings.costPolicy = "known-cost"; + settings.paidEligibility = "configured-connections"; + settings.modelControls[model.id] = { selection: "enabled" }; + const ids = eligibleModelsForRole({ + catalog, + settings, + role: "code-worker", + modalities: ["text"], + access: "write", + }).map((item) => item.id); + assert.equal(ids.includes(model.id), true); +}); From 87be12d13be2c59aa48a1c50b2908ef703bcf621 Mon Sep 17 00:00:00 2001 From: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:59:22 -0400 Subject: [PATCH 04/15] fix: accept provider-owned fetch transports on paid routes OpenCode subscription auth loaders attach an opaque fetch. Allow it on provider options when Paid policy is active and the exact model binding matches. Task and model route overrides stay rejected. Signed-off-by: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> --- src/opencode/plugin-runtime.js | 20 +++++++++++++++++--- test/opencode/live-routing-v3.test.js | 19 ++++++++++++++++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/opencode/plugin-runtime.js b/src/opencode/plugin-runtime.js index d9a7204..adeec93 100644 --- a/src/opencode/plugin-runtime.js +++ b/src/opencode/plugin-runtime.js @@ -264,13 +264,22 @@ function positiveRate(value) { typeof value === "object" && Object.values(value).some(positiveRate); } -function optionsMatch(options, api, depth = 0) { +function optionsMatch(options, api, depth = 0, allowOpaqueFetch = false) { if (!options || typeof options !== "object") return true; if (depth > 12) return false; for (const [key, value] of Object.entries(options)) { if (/^(headers|apiKey|token|accessToken|credentials|timeout)$/i.test(key)) continue; - if (/^(fetch|dispatcher|proxy|proxyUrl)$/i.test(key)) return false; + if (/^(fetch|dispatcher|proxy|proxyUrl)$/i.test(key)) { + if ( + allowOpaqueFetch && + depth === 0 && + /^fetch$/i.test(key) && + typeof value === "function" + ) + continue; + return false; + } if (/^(baseURL|baseUrl|url|endpoint|apiEndpoint)$/i.test(key)) { const normalized = normalizeApiIdentity({ ...api, url: value }); if ( @@ -624,7 +633,12 @@ export function createMediaRoutingHooks({ `${actual?.providerID}/${actual?.id}` !== route.id || !identityMatches(selected.api, actual?.api) || !hostSupports(actual, route.requirements) || - !optionsMatch(input.provider?.options, selected.api) || + !optionsMatch( + input.provider?.options, + selected.api, + 0, + current.settings.costPolicy === "known-cost", + ) || !optionsMatch(actual?.options, selected.api) || !optionsMatch(output?.options, selected.api) ) diff --git a/test/opencode/live-routing-v3.test.js b/test/opencode/live-routing-v3.test.js index 4970471..6fc37fe 100644 --- a/test/opencode/live-routing-v3.test.js +++ b/test/opencode/live-routing-v3.test.js @@ -365,13 +365,30 @@ test("effective provider mismatch or a custom transport cannot bypass endpoint i const o = await f.turn(); for (const mutate of [ (i) => (i.provider.id = "other"), - (i) => (i.provider.options.fetch = () => {}), (i) => (i.model.options.fetch = () => {}), ]) await assert.rejects(f.dispatch(o, "child", mutate), { code: "OMC_DISPATCH_IDENTITY_CONFLICT", }); }); +test("free policy does not certify an opaque provider fetch", async () => { + const f = fixture(); + const o = await f.turn(); + await assert.rejects( + f.dispatch(o, "child", (i) => { + i.provider.options.fetch = async () => new Response("{}"); + }), + { code: "OMC_DISPATCH_IDENTITY_CONFLICT" }, + ); +}); +test("provider-owned authentication fetch is accepted when the exact binding matches", async () => { + const f = fixture(); + f.settings.costPolicy = "known-cost"; + const o = await f.turn(); + await f.dispatch(o, "child", (i) => { + i.provider.options.fetch = async () => new Response("{}"); + }); +}); test("background review acknowledgement cannot authorize retained repair", async () => { const f = fixture(); await f.turn("omc-router", "parent"); From 517d8786c07c65c37de4b21ba2943b30d419f89b Mon Sep 17 00:00:00 2001 From: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:37:29 -0400 Subject: [PATCH 05/15] feat: connection-aware usage, panel copy, and 0.4.0 readiness Report tokens separately from OpenCode-recorded cost, keep missing values null, and store private HMAC attribution. The panel splits connection, access, and pricing, and existing Paid users must adopt configured-connection access. Bump package 0.4.0 and managed surface 3. Signed-off-by: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> --- CHANGELOG.md | 9 + CONTRIBUTING.md | 2 +- README.md | 4 +- SECURITY.md | 2 +- docs/opencode-integration.md | 8 +- ...-connection-billing-usage-execution-log.md | 38 ++++ package-lock.json | 4 +- package.json | 2 +- src/core/usage-accounting.js | 132 ++++++++++++ src/installer/index.js | 2 +- src/opencode/plugin-runtime.js | 42 ++++ src/opencode/plugin.js | 2 +- src/server/opencode-usage.js | 105 +++++---- src/server/service.js | 18 +- src/server/usage-attribution-store.js | 199 ++++++++++++++++++ src/ui/App.tsx | 2 +- src/ui/components/ModelTable.tsx | 52 ++++- src/ui/components/RoleAssignments.tsx | 20 +- src/ui/components/UsagePanel.tsx | 20 +- src/ui/model-control.js | 29 ++- src/ui/types.ts | 30 ++- test/core/usage-accounting.test.js | 60 ++++++ test/installer/installer.test.js | 4 +- test/server/opencode-usage.test.js | 31 ++- test/server/usage-attribution-store.test.js | 40 ++++ test/ui/model-control.test.js | 6 +- test/ui/policy-v3.test.js | 2 +- test/ui/ui-contract.test.js | 4 +- 28 files changed, 771 insertions(+), 98 deletions(-) create mode 100644 src/core/usage-accounting.js create mode 100644 src/server/usage-attribution-store.js create mode 100644 test/core/usage-accounting.test.js create mode 100644 test/server/usage-attribution-store.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2048ca1..3393c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to OpenCode Model Control are recorded here. The project follows [Semantic Versioning](https://semver.org/). +## 0.4.0 + +- Treat empty SDK-default endpoints as unspecified rather than invalid, without letting a missing public URL certify a custom gateway. +- Accept OpenCode provider-owned authentication transports on Paid routes when the exact model binding matches. Do not inspect transport source or fall back to another billing connection. +- Add connection snapshots and settings schema 4. Existing Paid installs migrate to `verified-pricing`; selecting the new Paid control adopts `configured-connections`. +- Free routing still requires verified free prices. Configured Paid access can use a host route when public estimates are unavailable. +- Report tokens separately from OpenCode-recorded cost. Missing cost stays unreported. Quota may be not reported. Historical usage is not relabelled after a login change. +- Managed integration surface version 3; an explicit Connect/update and OpenCode restart is required for plugin behavior changes. + ## 0.3.0 - Replace historical free-model authorization with exact provider/model/API pricing evidence from credential-free public Models.dev metadata, including all supported supplied billing dimensions. Refresh every 15 minutes while active; expire evidence after 24 hours and block missing, conflicting, malformed, or expired pricing at dispatch. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10d34fd..2663051 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ Documentation-only changes should still run `npm run verify` when practical. Sta 4. Update documentation when behavior, support, security, or benchmark claims change. 5. Run `npm run verify` and report any check you could not run. -Routing changes must keep unknown/expired pricing blocked and preserve the verified-free default. Saving Paid is the explicit authorization for known-paid routing; with auto-include on, it also authorizes future eligible known-paid models without a per-model click. Preserve explicit disables and never write inferred enrollment as saved intent. Public metadata cannot expand host-effective capabilities. Preserve existing ranking and defer authentication integrations unless separately approved. Connector changes must preserve unrelated OpenCode configuration, fail closed on ownership conflicts, and include isolated install/disconnect tests. +Routing changes must preserve the verified-free default. Free and migrated `verified-pricing` Paid still block unknown/expired public prices. After a user saves the new Paid control (`configured-connections`), a configured host route may be eligible without a public estimate; malformed endpoints and changed bindings stay blocked. Preserve explicit disables and never write inferred enrollment as saved intent. Public metadata cannot expand host-effective capabilities or certify a custom endpoint. Preserve existing ranking and defer authentication integrations unless separately approved. Connector changes must preserve unrelated OpenCode configuration, fail closed on ownership conflicts, and include isolated install/disconnect tests. Do not include credentials, private prompts, user transcripts, proprietary source code, benchmark data you cannot redistribute, or code copied from closed-source routers. Contributions must be clean-room work or compatible third-party material with its provenance and license recorded. diff --git a/README.md b/README.md index 10b04fb..c8a3c7b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The control panel runs on `127.0.0.1`. OpenCode remains responsible for provider The running app is authoritative for model names, availability, pricing evidence, and role eligibility. -> This source documents **0.3.0**; `@latest` installs the version currently published on [npm](https://www.npmjs.com/package/opencode-model-control). Check the [release index](https://github.com/BitL8-ByteShort/opencode-model-control/releases) for availability and the [support matrix](docs/support-matrix.md) for verified compatibility. +> This source documents **0.4.0**; `@latest` installs the version currently published on [npm](https://www.npmjs.com/package/opencode-model-control). Check the [release index](https://github.com/BitL8-ByteShort/opencode-model-control/releases) for availability and the [support matrix](docs/support-matrix.md) for verified compatibility. 0.4.0 is implemented in this tree; public publication is a separate authorized gate. ## What it does @@ -110,7 +110,7 @@ The connector writes absolute Node and package CLI paths, so a source checkout d The [GitHub release index](https://github.com/BitL8-ByteShort/opencode-model-control/releases) lists published versioned tarballs and checksums. Download the exact release asset, verify its SHA-256 against that release's checksum, then install the local file with `npm install --global /absolute/path/to/downloaded-package.tgz`. Historical package digests are recorded in the [historical package ledger](https://github.com/BitL8-ByteShort/opencode-model-control/blob/v0.2.1/packages/README.md). The [release checklist](docs/releasing.md) contains the maintainer-only 0.3.0 publication and verification procedure. -## What “Update available models” means (0.3.0) +## What “Update available models” means (0.4.0) The button asks the installed OpenCode CLI for its effective model list with plugin-aware discovery and `--refresh`. This reflects OpenCode's resolved provider configuration, including its provider and model filters. diff --git a/SECURITY.md b/SECURITY.md index fe13861..5113d9c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -40,7 +40,7 @@ The manual runtime access check is never automatic. It requires explicit provide Catalog refresh sends a credential-free request only to the fixed `https://models.dev/api.json` metadata endpoint, with JSON accept and conditional ETag/Last-Modified headers. Redirects are rejected; request time and response size are bounded (8 seconds, 32 MiB). No metadata-provided URL is fetched and no prompt, attachment, usage, selected-model list, local config, or provider credential is sent. Public metadata servers still receive ordinary request/network metadata. The private conditional cache carries an exact source digest and retrieval/expiry times; failed retrieval never extends the 24-hour pricing lifetime. Refresh checks run every 15 minutes while active and on stale startup or manual request. -Pricing is matched by the exact provider/full model key and API identity (model ID, npm adapter, and normalized endpoint). A similarly named model, a `-free` suffix, arbitrary CLI zeros, and bundled historical evidence cannot authorize free routing. Model Control fetches the fixed public `https://models.dev/api.json` endpoint without credentials; URLs inside metadata are never fetched. Complete, finite, nonnegative input/output rates are required. Every supported supplied billing dimension counts: reasoning, cache read/write, audio input/output, context tiers, legacy over-200k rates, and experimental modes. With complete valid evidence, any positive rate means paid; all supplied rates must be valid and exactly zero for free. Missing, malformed, unsupported, or conflicting evidence is unknown and blocked. Complete positive CLI evidence can establish `reported-paid` when independent evidence does not contradict it; CLI zero cannot establish free. +Pricing is matched by the exact provider/full model key and API identity (model ID, npm adapter, and normalized endpoint). Raw empty, absent, and null URLs are unspecified SDK defaults, not a wildcard for custom endpoints. A similarly named model, a `-free` suffix, arbitrary CLI zeros, and bundled historical evidence cannot authorize free routing. Model Control fetches the fixed public `https://models.dev/api.json` endpoint without credentials; URLs inside metadata are never fetched. Complete, finite, nonnegative input/output rates are required. Every supported supplied billing dimension counts: reasoning, cache read/write, audio input/output, context tiers, legacy over-200k rates, and experimental modes. With complete valid evidence, any positive rate means paid; all supplied rates must be valid and exactly zero for free. Missing, malformed, unsupported, or conflicting evidence is unknown. Unknown prices cannot authorize Free or migrated verified-pricing Paid. After the user saves configured-connection Paid access, a configured host route may be used without a public estimate. Complete positive CLI evidence can establish `reported-paid` when independent evidence does not contradict it; CLI zero cannot establish free, and CLI cost cannot override a public-price route mismatch. Provider-owned authentication transports are not inspected or logged. Pricing evidence expires after **24 hours**, checked at route time even without another refresh. Successful HTTP 200 or cached 304 revalidation renews public-source freshness; a failed attempt does not. Cached evidence remains usable only until its existing expiry. Public-source digests and timestamps describe retrieved metadata, not a billing guarantee or model-quality score. diff --git a/docs/opencode-integration.md b/docs/opencode-integration.md index 62fd521..6a9d08c 100644 --- a/docs/opencode-integration.md +++ b/docs/opencode-integration.md @@ -28,9 +28,13 @@ A refresh does not invoke a model, confirm an entitlement, prove successful prov Startup refreshes stale metadata before initialization completes; a live service checks every **15 minutes**, and **Update available models** can request an immediate refresh. A shared refresh lease coalesces panel/MCP processes; a recent persisted attempt prevents duplicate periodic work. OpenCode discovery and the independent public metadata fetch run concurrently. Failed or incomplete discovery retains the last usable model records; complete discovery can mark an absent model unavailable while preserving its identity and saved choices. The panel distinguishes last attempt, last successful discovery, and last successful pricing retrieval. A failed refresh cannot renew pricing freshness. Refresh does not invoke provider inference or rewrite OpenCode config; OpenCode itself may normalize its standard `$schema` field. -Pricing is matched by the exact provider/full model key and API identity (model ID, npm adapter, and normalized endpoint). A similarly named model, a `-free` suffix, arbitrary CLI zeros, and bundled historical evidence cannot authorize free routing. Model Control fetches the fixed public `https://models.dev/api.json` endpoint without credentials; URLs inside metadata are never fetched. Complete, finite, nonnegative input/output rates are required. Every supported supplied billing dimension counts: reasoning, cache read/write, audio input/output, context tiers, legacy over-200k rates, and experimental modes. With complete valid evidence, any positive rate means paid; all supplied rates must be valid and exactly zero for free. Missing, malformed, unsupported, or conflicting evidence is unknown and blocked. Complete positive CLI evidence can establish `reported-paid` when independent evidence does not contradict it; CLI zero cannot establish free. +A **connection** is the configured OpenCode provider slot used to reach a model. Billing kind (subscription, metered API, prepaid, local, or unknown) is observed from host evidence or an explicit user declaration. Authentication method alone does not determine billing: an API key can be a coding-plan subscription, and OAuth is not proof of entitlement. -Pricing evidence expires after **24 hours**, checked at route time even without another refresh. Successful HTTP 200 or cached 304 revalidation renews public-source freshness; a failed attempt does not. Cached evidence remains usable only until its existing expiry. Public-source digests and timestamps describe retrieved metadata, not a billing guarantee or model-quality score. +Pricing is matched by the exact provider/full model key and API identity (model ID, npm adapter, and normalized endpoint). Raw empty, absent, and null URLs are unspecified SDK defaults; they are not invalid and are not a wildcard for custom gateways. A similarly named model, a `-free` suffix, arbitrary CLI zeros, and bundled historical evidence cannot authorize free routing. Model Control fetches the fixed public `https://models.dev/api.json` endpoint without credentials; URLs inside metadata are never fetched. Complete, finite, nonnegative input/output rates are required. Every supported supplied billing dimension counts: reasoning, cache read/write, audio input/output, context tiers, legacy over-200k rates, and experimental modes. With complete valid evidence, any positive rate means paid; all supplied rates must be valid and exactly zero for free. Missing, malformed, unsupported, or conflicting evidence is unknown. Unknown prices cannot authorize **Free** or migrated **verified-pricing Paid**. After the user saves the new Paid control (`configured-connections`), a configured host route may be used when estimates are unavailable. Complete positive CLI evidence can establish `reported-paid` when independent evidence does not contradict it; CLI zero cannot establish free, and CLI cost cannot override a public-price route mismatch. + +OpenCode owns execution transport. A provider-owned authentication `fetch` may be accepted on Paid routes when the exact selected provider/model and observable connection binding match. Transport visibility is host-managed in that case; Model Control does not claim to have verified the network destination. Task or model route overrides, changed endpoints, and opaque transports under Free policy remain blocked. There is no automatic fallback from a subscription connection to metered API billing. + +Pricing evidence expires after **24 hours** for Free and legacy verified-pricing access, checked at route time even without another refresh. Configured-connection Paid can continue with a stale-estimate warning. Successful HTTP 200 or cached 304 revalidation renews public-source freshness; a failed attempt does not. Cached evidence remains usable only until its existing expiry. Public-source digests and timestamps describe retrieved metadata, not a billing guarantee, subscription quota, or model-quality score. Quota is **Not reported** unless a supported host adapter actually exposes it. Historical OpenCode usage is not classified from today's login. ## Managed config surface diff --git a/docs/plans/2026-09-08-connection-billing-usage-execution-log.md b/docs/plans/2026-09-08-connection-billing-usage-execution-log.md index a7c586d..dbe01b8 100644 --- a/docs/plans/2026-09-08-connection-billing-usage-execution-log.md +++ b/docs/plans/2026-09-08-connection-billing-usage-execution-log.md @@ -59,3 +59,41 @@ These are not verified fixes. The correct Task 2 contract treats raw `''`/absent ## Task 3 +**Files:** `src/core/connections.js`, `src/server/connection-store.js`, `src/opencode/connection-observer.js`, settings v4, schemas, snapshot wiring. + +**Result:** Existing Paid migrates to `paidEligibility: verified-pricing`. Fresh installs stay Free. One host slot is one connection; API keys are not auto-labelled metered API. `node --test` on connection/settings/schema tests: 49 pass. + +**Commit:** `ef07545` + +## Task 4 + +**Files:** `src/core/eligibility.js`, `src/core/catalog.js`, `src/server/service.js` + +**Result:** Shared `resolveEligibility`. Configured-connection Paid allows unknown estimates; Free and legacy Paid stay verified-price gated. Invalid endpoints and binding changes still block. + +**Commit:** `55901ca` + +## Task 5 + +**Files:** `src/opencode/plugin-runtime.js`, live-routing tests + +**Result:** Provider-level opaque `fetch` accepted under Paid when binding matches. Free still rejects it. Model/task fetch and endpoint overrides still conflict. Hook tests pass; full isolated host auth-loader path not yet run (`npm run test:host`). + +**Commit:** `87be12d` + +## Task 6 + +**Files:** `src/core/usage-accounting.js`, `src/server/usage-attribution-store.js`, `src/server/opencode-usage.js`, plugin dispatch capture. + +**Result:** Missing cost/tokens stay null. Usage schema 2 labels OpenCode-recorded cost. Quota may be not reported. Attribution store uses private HMAC keys, 90-day/10k/10MiB bounds. Plugin records pending observations without blocking dispatch. + +## Task 7 + +**Files:** Model table connection/access/pricing columns, Paid adoption notice, usage panel labels. + +## Task 8 + +Package 0.4.0, managed surface 3, changelog, CONTRIBUTING, integration, SECURITY, README. No public publish. `npm run test:host` / `test:browser` / packaged acceptance not run in this session. + +Pre-existing env: `production-entry` test times out on this Node 24 `--experimental-loader` warning. Source tests otherwise 317/318. + diff --git a/package-lock.json b/package-lock.json index 79212c1..e4468a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-model-control", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-model-control", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/server": "2.0.0", diff --git a/package.json b/package.json index addab17..c75ac67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-model-control", - "version": "0.3.0", + "version": "0.4.0", "description": "A local model routing control panel and MCP companion for OpenCode.", "keywords": [ "opencode", diff --git a/src/core/usage-accounting.js b/src/core/usage-accounting.js new file mode 100644 index 0000000..b9361b1 --- /dev/null +++ b/src/core/usage-accounting.js @@ -0,0 +1,132 @@ +import { routerError } from "./errors.js"; +import { isPlainObject } from "./utils.js"; +import { BILLING_KINDS, EVIDENCE_SOURCES } from "./connections.js"; + +const TOKEN_KEYS = Object.freeze([ + "input", + "output", + "reasoning", + "cacheRead", + "cacheWrite", +]); + +function invalidUsage(message) { + throw routerError("INVALID_USAGE", message); +} + +export function nullableFinite(value) { + if (value === null || value === undefined) return null; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) + invalidUsage("Usage values must be finite and nonnegative."); + return value; +} + +export function sumNullable(values) { + const present = values.filter((value) => value !== null && value !== undefined); + if (!present.length) return null; + const total = present.reduce((sum, value) => sum + value, 0); + return Number.isFinite(total) ? total : null; +} + +export function estimateApiCost({ rates, tokens, semantics = {} } = {}) { + if (!isPlainObject(rates) || !isPlainObject(tokens)) { + return { amount: null, status: "unavailable", currency: null }; + } + const input = nullableFinite(tokens.input); + const output = nullableFinite(tokens.output); + if (input === null || output === null || rates.input == null || rates.output == null) { + return { amount: null, status: "unavailable", currency: null }; + } + let amount = (input * rates.input + output * rates.output) / 1_000_000; + if (semantics.reasoningIncludedInOutput !== true && tokens.reasoning != null) { + if (rates.reasoning == null) + return { amount: null, status: "partial", currency: "USD", incomplete: true }; + amount += (nullableFinite(tokens.reasoning) * rates.reasoning) / 1_000_000; + } + if (semantics.cacheIncludedInInput !== true) { + for (const [key, rateKey] of [ + ["cacheRead", "cache_read"], + ["cacheWrite", "cache_write"], + ]) { + if (tokens[key] == null) continue; + if (rates[rateKey] == null) + return { amount: null, status: "partial", currency: "USD", incomplete: true }; + amount += (nullableFinite(tokens[key]) * rates[rateKey]) / 1_000_000; + } + } + if (semantics.audioRequired && (rates.input_audio == null || rates.output_audio == null)) { + return { amount: null, status: "unavailable", currency: null }; + } + return { + amount: Number.isFinite(amount) ? amount : null, + status: "estimated-api-cost", + currency: "USD", + }; +} + +export function validateUsageObservation(value) { + if (!isPlainObject(value)) invalidUsage("Usage observation is invalid."); + if (typeof value.eventKey !== "string" || !/^[a-f0-9]{64}$/.test(value.eventKey)) + invalidUsage("Usage observation identity is invalid."); + if ( + typeof value.observedAt !== "string" || + !Number.isFinite(Date.parse(value.observedAt)) + ) + invalidUsage("Usage observation time is invalid."); + if ( + value.connectionId !== null && + (typeof value.connectionId !== "string" || + !/^[a-f0-9]{32}$/.test(value.connectionId)) + ) + invalidUsage("Usage observation connection is invalid."); + if ( + value.bindingRevision !== null && + (typeof value.bindingRevision !== "string" || + !/^[a-f0-9]{32}$/.test(value.bindingRevision)) + ) + invalidUsage("Usage observation binding is invalid."); + if (!BILLING_KINDS.includes(value.billingKind)) + invalidUsage("Usage observation billing is invalid."); + if (!EVIDENCE_SOURCES.includes(value.billingSource)) + invalidUsage("Usage observation billing is invalid."); + const tokens = value.tokens ?? {}; + const recorded = + value.recordedCost === null || value.recordedCost === undefined + ? null + : { + amount: nullableFinite(value.recordedCost.amount), + currency: + value.recordedCost.currency === null || + value.recordedCost.currency === undefined + ? null + : typeof value.recordedCost.currency === "string" + ? value.recordedCost.currency + : invalidUsage("Usage observation currency is invalid."), + }; + return { + eventKey: value.eventKey, + observedAt: value.observedAt, + connectionId: value.connectionId, + bindingRevision: value.bindingRevision, + billingKind: value.billingKind, + billingSource: value.billingSource, + tokens: Object.fromEntries( + TOKEN_KEYS.map((key) => [key, nullableFinite(tokens[key] ?? null)]), + ), + recordedCost: recorded, + priceSnapshotId: + value.priceSnapshotId === null || value.priceSnapshotId === undefined + ? null + : typeof value.priceSnapshotId === "string" + ? value.priceSnapshotId + : invalidUsage("Usage observation price snapshot is invalid."), + }; +} + +export function costLabel(kind) { + if (kind === "opencode-recorded") return "OpenCode-recorded cost"; + if (kind === "estimated-api") return "Estimated API cost"; + if (kind === "api-equivalent") return "API-equivalent estimate"; + if (kind === "provider-charge") return "Provider-reported charge"; + return "Not reported"; +} diff --git a/src/installer/index.js b/src/installer/index.js index 016fafb..2b7f904 100644 --- a/src/installer/index.js +++ b/src/installer/index.js @@ -35,7 +35,7 @@ const MAX_MCP_OUTPUT_BYTES = 1024 * 1024; const MCP_HANDSHAKE_TIMEOUT_MS = 10_000; const MCP_PROTOCOL_VERSION = "2025-11-25"; const RECEIPT_SCHEMA_VERSION = 1; -const MANAGED_SURFACE_VERSION = 2; +const MANAGED_SURFACE_VERSION = 3; const OWNED_ROOTS = ["mcp", "tools", "agent"]; const OWNED_PATHS = [ ["mcp", "model-control"], diff --git a/src/opencode/plugin-runtime.js b/src/opencode/plugin-runtime.js index adeec93..d721d6f 100644 --- a/src/opencode/plugin-runtime.js +++ b/src/opencode/plugin-runtime.js @@ -303,10 +303,44 @@ function optionsMatch(options, api, depth = 0, allowOpaqueFetch = false) { return true; } +async function captureAttribution({ settingsPath, input, selected, current }) { + const { + attributionEventKey, + readOrCreateAttributionSalt, + upsertUsageObservation, + } = await import("../server/usage-attribution-store.js"); + const salt = await readOrCreateAttributionSalt(settingsPath); + const role = OWNED_ROLES[input.agent]; + const binding = current.settings.roleConnections?.[role] ?? null; + await upsertUsageObservation({ + settingsPath, + pending: true, + observation: { + eventKey: attributionEventKey(salt, input.sessionID, input.message.id), + observedAt: new Date().toISOString(), + connectionId: binding?.connectionId ?? null, + bindingRevision: binding?.bindingRevision ?? null, + billingKind: "unknown", + billingSource: "unknown", + tokens: { + input: null, + output: null, + reasoning: null, + cacheRead: null, + cacheWrite: null, + }, + recordedCost: null, + priceSnapshotId: selected.pricing?.digest ?? null, + }, + }); +} + export function createMediaRoutingHooks({ loadPolicy = loadSavedRoutingPolicy, client, directory, + recordUsage = false, + settingsPath = resolveSettingsPath(), } = {}) { const routes = new Map(); const readOnlySessions = new Set(); @@ -655,6 +689,14 @@ export function createMediaRoutingHooks({ (selected.pricing.class === "free" && positiveRate(actual.cost)) ) fail("OMC_DISPATCH_PRICING_CONFLICT"); + if (recordUsage) { + void captureAttribution({ + settingsPath, + input, + selected, + current, + }).catch(() => {}); + } }, async "permission.ask"(input, output) { if (readOnlySessions.has(input?.sessionID)) output.status = "deny"; diff --git a/src/opencode/plugin.js b/src/opencode/plugin.js index 0a6da19..d54e520 100644 --- a/src/opencode/plugin.js +++ b/src/opencode/plugin.js @@ -3,4 +3,4 @@ import { createMediaRoutingHooks } from "./plugin-runtime.js"; // Keep this module's public surface to plugin functions only. OpenCode loads // every plugin function exported by a local plugin module. export const OmcRouterPlugin = async ({ client, directory } = {}) => - createMediaRoutingHooks({ client, directory }); + createMediaRoutingHooks({ client, directory, recordUsage: true }); diff --git a/src/server/opencode-usage.js b/src/server/opencode-usage.js index 9ed1f8f..4f97c9c 100644 --- a/src/server/opencode-usage.js +++ b/src/server/opencode-usage.js @@ -14,9 +14,11 @@ const MAX_MODEL_ROWS = 250; const PROVIDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:+/-]*$/u; const CAVEATS = Object.freeze([ - "Token and cost values are recorded by OpenCode from provider responses; zero values may mean the provider did not report usage.", - "Recorded cost is not a provider bill.", + "Token counts are usage. OpenCode-recorded cost is not a provider bill or subscription charge.", + "Missing cost or token fields stay unreported instead of becoming zero.", + "Historical OpenCode totals are not classified by today's login or billing mode.", "Cache-read tokens are cumulative usage, not the current context size.", + "Plan quota is not reported unless a supported host adapter exposes it.", ]); function usageError(code, message, statusCode) { @@ -64,30 +66,36 @@ export function usageSqlForWindow(input = DEFAULT_USAGE_WINDOW) { THEN json_extract(message.data, '$.modelID') END AS model_id, CASE WHEN json_type(message.data, '$.cost') IN ('integer', 'real') AND json_extract(message.data, '$.cost') >= 0 - THEN json_extract(message.data, '$.cost') ELSE 0 END AS cost_usd, + THEN json_extract(message.data, '$.cost') END AS cost_usd, CASE WHEN json_type(message.data, '$.tokens.input') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.input') >= 0 - THEN json_extract(message.data, '$.tokens.input') ELSE 0 END AS tokens_input, + THEN json_extract(message.data, '$.tokens.input') END AS tokens_input, CASE WHEN json_type(message.data, '$.tokens.output') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.output') >= 0 - THEN json_extract(message.data, '$.tokens.output') ELSE 0 END AS tokens_output, + THEN json_extract(message.data, '$.tokens.output') END AS tokens_output, CASE WHEN json_type(message.data, '$.tokens.reasoning') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.reasoning') >= 0 - THEN json_extract(message.data, '$.tokens.reasoning') ELSE 0 END AS tokens_reasoning, + THEN json_extract(message.data, '$.tokens.reasoning') END AS tokens_reasoning, CASE WHEN json_type(message.data, '$.tokens.cache.read') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.cache.read') >= 0 - THEN json_extract(message.data, '$.tokens.cache.read') ELSE 0 END AS tokens_cache_read, + THEN json_extract(message.data, '$.tokens.cache.read') END AS tokens_cache_read, CASE WHEN json_type(message.data, '$.tokens.cache.write') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.cache.write') >= 0 - THEN json_extract(message.data, '$.tokens.cache.write') ELSE 0 END AS tokens_cache_write, + THEN json_extract(message.data, '$.tokens.cache.write') END AS tokens_cache_write, CASE WHEN - json_type(message.data, '$.cost') IN ('integer', 'real') AND json_extract(message.data, '$.cost') >= 0 AND - json_type(message.data, '$.tokens.input') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.input') >= 0 AND - json_type(message.data, '$.tokens.output') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.output') >= 0 AND - json_type(message.data, '$.tokens.reasoning') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.reasoning') >= 0 AND - json_type(message.data, '$.tokens.cache.read') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.cache.read') >= 0 AND - json_type(message.data, '$.tokens.cache.write') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.cache.write') >= 0 - THEN 0 ELSE 1 END AS invalid_accounting + (json_type(message.data, '$.cost') IS NOT NULL AND json_type(message.data, '$.cost') != 'null' + AND (json_type(message.data, '$.cost') NOT IN ('integer', 'real') OR json_extract(message.data, '$.cost') < 0)) + OR (json_type(message.data, '$.tokens.input') IS NOT NULL AND json_type(message.data, '$.tokens.input') != 'null' + AND (json_type(message.data, '$.tokens.input') NOT IN ('integer', 'real') OR json_extract(message.data, '$.tokens.input') < 0)) + OR (json_type(message.data, '$.tokens.output') IS NOT NULL AND json_type(message.data, '$.tokens.output') != 'null' + AND (json_type(message.data, '$.tokens.output') NOT IN ('integer', 'real') OR json_extract(message.data, '$.tokens.output') < 0)) + OR (json_type(message.data, '$.tokens.reasoning') IS NOT NULL AND json_type(message.data, '$.tokens.reasoning') != 'null' + AND (json_type(message.data, '$.tokens.reasoning') NOT IN ('integer', 'real') OR json_extract(message.data, '$.tokens.reasoning') < 0)) + OR (json_type(message.data, '$.tokens.cache.read') IS NOT NULL AND json_type(message.data, '$.tokens.cache.read') != 'null' + AND (json_type(message.data, '$.tokens.cache.read') NOT IN ('integer', 'real') OR json_extract(message.data, '$.tokens.cache.read') < 0)) + OR (json_type(message.data, '$.tokens.cache.write') IS NOT NULL AND json_type(message.data, '$.tokens.cache.write') != 'null' + AND (json_type(message.data, '$.tokens.cache.write') NOT IN ('integer', 'real') OR json_extract(message.data, '$.tokens.cache.write') < 0)) + THEN 1 ELSE 0 END AS invalid_accounting FROM message WHERE json_extract(message.data, '$.role') = 'assistant' AND ${windowFilter(window)} @@ -97,15 +105,15 @@ export function usageSqlForWindow(input = DEFAULT_USAGE_WINDOW) { model_id, COUNT(DISTINCT session_id) AS sessions, COUNT(*) AS messages, - COALESCE(SUM(cost_usd), 0) AS cost_usd, - COALESCE(SUM(tokens_input), 0) AS tokens_input, - COALESCE(SUM(tokens_output), 0) AS tokens_output, - COALESCE(SUM(tokens_reasoning), 0) AS tokens_reasoning, - COALESCE(SUM(tokens_cache_read), 0) AS tokens_cache_read, - COALESCE(SUM(tokens_cache_write), 0) AS tokens_cache_write, + SUM(cost_usd) AS cost_usd, + SUM(tokens_input) AS tokens_input, + SUM(tokens_output) AS tokens_output, + SUM(tokens_reasoning) AS tokens_reasoning, + SUM(tokens_cache_read) AS tokens_cache_read, + SUM(tokens_cache_write) AS tokens_cache_write, MIN(time_created) AS earliest, MAX(time_created) AS latest, - SUM(CASE WHEN tokens_input + tokens_output + tokens_reasoning + tokens_cache_read + tokens_cache_write = 0 THEN 1 ELSE 0 END) AS zero_token_messages + SUM(CASE WHEN tokens_input IS NOT NULL AND tokens_output IS NOT NULL AND COALESCE(tokens_input, 0) + COALESCE(tokens_output, 0) + COALESCE(tokens_reasoning, 0) + COALESCE(tokens_cache_read, 0) + COALESCE(tokens_cache_write, 0) = 0 THEN 1 ELSE 0 END) AS zero_token_messages FROM filtered WHERE provider_id IS NOT NULL AND model_id IS NOT NULL GROUP BY provider_id, model_id @@ -120,18 +128,18 @@ SELECT NULL AS model_id, COUNT(DISTINCT session_id) AS sessions, COUNT(*) AS messages, - COALESCE(SUM(cost_usd), 0) AS cost_usd, - COALESCE(SUM(tokens_input), 0) AS tokens_input, - COALESCE(SUM(tokens_output), 0) AS tokens_output, - COALESCE(SUM(tokens_reasoning), 0) AS tokens_reasoning, - COALESCE(SUM(tokens_cache_read), 0) AS tokens_cache_read, - COALESCE(SUM(tokens_cache_write), 0) AS tokens_cache_write, + SUM(cost_usd) AS cost_usd, + SUM(tokens_input) AS tokens_input, + SUM(tokens_output) AS tokens_output, + SUM(tokens_reasoning) AS tokens_reasoning, + SUM(tokens_cache_read) AS tokens_cache_read, + SUM(tokens_cache_write) AS tokens_cache_write, MIN(time_created) AS earliest, MAX(time_created) AS latest, COUNT(DISTINCT CASE WHEN provider_id IS NOT NULL AND model_id IS NOT NULL THEN provider_id || char(0) || model_id END) AS model_count, COALESCE(SUM(CASE WHEN provider_id IS NULL OR model_id IS NULL THEN 1 ELSE 0 END), 0) AS unattributed_messages, - COALESCE(SUM(CASE WHEN tokens_input + tokens_output + tokens_reasoning + tokens_cache_read + tokens_cache_write = 0 THEN 1 ELSE 0 END), 0) AS zero_token_messages, + COALESCE(SUM(CASE WHEN tokens_input IS NOT NULL AND tokens_output IS NOT NULL AND COALESCE(tokens_input, 0) + COALESCE(tokens_output, 0) + COALESCE(tokens_reasoning, 0) + COALESCE(tokens_cache_read, 0) + COALESCE(tokens_cache_write, 0) = 0 THEN 1 ELSE 0 END), 0) AS zero_token_messages, COALESCE(SUM(invalid_accounting), 0) AS invalid_accounting_messages FROM filtered UNION ALL @@ -156,7 +164,11 @@ SELECT FROM model_usage`; } -function finiteNumber(value, field, { integer = false } = {}) { +function finiteNumber(value, field, { integer = false, optional = false } = {}) { + if (value === null || value === undefined) { + if (optional) return null; + throw usageError("OPENCODE_USAGE_INVALID", `OpenCode returned invalid ${field} usage.`, 502); + } if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { throw usageError("OPENCODE_USAGE_INVALID", `OpenCode returned invalid ${field} usage.`, 502); } @@ -178,14 +190,15 @@ function timestamp(value, field) { function tokenCounts(row) { const tokens = { - input: finiteNumber(row.tokens_input, "input token", { integer: true }), - output: finiteNumber(row.tokens_output, "output token", { integer: true }), - reasoning: finiteNumber(row.tokens_reasoning, "reasoning token", { integer: true }), - cacheRead: finiteNumber(row.tokens_cache_read, "cache-read token", { integer: true }), - cacheWrite: finiteNumber(row.tokens_cache_write, "cache-write token", { integer: true }), + input: finiteNumber(row.tokens_input, "input token", { integer: true, optional: true }), + output: finiteNumber(row.tokens_output, "output token", { integer: true, optional: true }), + reasoning: finiteNumber(row.tokens_reasoning, "reasoning token", { integer: true, optional: true }), + cacheRead: finiteNumber(row.tokens_cache_read, "cache-read token", { integer: true, optional: true }), + cacheWrite: finiteNumber(row.tokens_cache_write, "cache-write token", { integer: true, optional: true }), }; - const total = Object.values(tokens).reduce((sum, value) => sum + value, 0); - if (!Number.isSafeInteger(total)) { + const present = Object.values(tokens).filter((value) => value !== null); + const total = present.length ? present.reduce((sum, value) => sum + value, 0) : null; + if (total !== null && !Number.isSafeInteger(total)) { throw usageError("OPENCODE_USAGE_INVALID", "OpenCode returned an unsafe token total.", 502); } return { ...tokens, total }; @@ -236,7 +249,7 @@ export function parseOpenCodeUsageRows(stdout, { const totals = { sessions: finiteNumber(summary.sessions, "session", { integer: true }), messages: finiteNumber(summary.messages, "message", { integer: true }), - costUsd: finiteNumber(summary.cost_usd, "cost"), + costUsd: finiteNumber(summary.cost_usd, "cost", { optional: true }), tokens: tokenCounts(summary), }; const modelsSeen = finiteNumber(summary.model_count, "model", { integer: true }); @@ -255,7 +268,7 @@ export function parseOpenCodeUsageRows(stdout, { modelId, sessions: finiteNumber(row.sessions, "model session", { integer: true }), messages: finiteNumber(row.messages, "model message", { integer: true }), - costUsd: finiteNumber(row.cost_usd, "model cost"), + costUsd: finiteNumber(row.cost_usd, "model cost", { optional: true }), tokens: tokenCounts(row), }; }).sort((left, right) => { @@ -271,9 +284,10 @@ export function parseOpenCodeUsageRows(stdout, { } return { - schemaVersion: 1, + schemaVersion: 2, source: "opencode-local-accounting", - accounting: "provider-reported", + accounting: "opencode-recorded", + costLabel: "OpenCode-recorded cost", window, windowDays: USAGE_WINDOWS[window], generatedAt: generated.toISOString(), @@ -296,6 +310,15 @@ export function parseOpenCodeUsageRows(stdout, { earliestMessageAt: timestamp(summary.earliest, "earliest timestamp"), latestMessageAt: timestamp(summary.latest, "latest timestamp"), }, + quota: { status: "not-reported" }, + attributed: { + observations: [], + coverage: { + firstObservedAt: null, + droppedCount: 0, + truncated: false, + }, + }, caveats: [...CAVEATS], }; } diff --git a/src/server/service.js b/src/server/service.js index 0c836dc..b31e6e0 100644 --- a/src/server/service.js +++ b/src/server/service.js @@ -25,6 +25,7 @@ import { import { classifyRouteRequest } from "./task-classifier.js"; import { discoverOpenCode, mergeDiscoveredCatalog } from "./opencode-cli.js"; import { readOpenCodeUsage } from "./opencode-usage.js"; +import { readUsageAttribution } from "./usage-attribution-store.js"; import { runOpenCodeRuntimeQualification } from "./runtime-qualification.js"; import { appendRuntimeQualificationResult, @@ -617,7 +618,22 @@ export class ControlService { } async getUsage(window) { - return this.usageReader({ window }); + const usage = await this.usageReader({ window }); + try { + usage.attributed = await readUsageAttribution({ + settingsPath: this.settingsPath, + }); + } catch { + usage.attributed = { + observations: [], + coverage: { + firstObservedAt: null, + droppedCount: 0, + truncated: true, + }, + }; + } + return usage; } async getOpenCodeIntegration() { diff --git a/src/server/usage-attribution-store.js b/src/server/usage-attribution-store.js new file mode 100644 index 0000000..b090849 --- /dev/null +++ b/src/server/usage-attribution-store.js @@ -0,0 +1,199 @@ +import { createHmac, randomBytes, createHash } from "node:crypto"; +import { + chmod, + lstat, + mkdir, + readFile, + rename, + unlink, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { validateUsageObservation } from "../core/usage-accounting.js"; +import { withStateLock } from "./state-lock.js"; + +const MAX_RECORDS = 10_000; +const MAX_BYTES = 10 * 1024 * 1024; +const MAX_PENDING = 1_000; +const RETENTION_MS = 90 * 24 * 60 * 60 * 1000; +const MAX_FILE_BYTES = MAX_BYTES; + +export function resolveAttributionPath(settingsPath) { + return join(dirname(settingsPath), "usage-attribution.json"); +} + +function resolveSaltPath(settingsPath) { + return join(dirname(settingsPath), "usage-attribution.salt"); +} + +export async function readOrCreateAttributionSalt(settingsPath) { + const path = resolveSaltPath(settingsPath); + try { + const salt = await readFile(path); + if (salt.length >= 32) return salt; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + const salt = randomBytes(32); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await chmod(dirname(path), 0o700); + const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`; + await writeFile(temporary, salt, { flag: "wx", mode: 0o600 }); + await rename(temporary, path); + await chmod(path, 0o600); + return salt; +} + +export function attributionEventKey(salt, sessionId, messageId) { + return createHmac("sha256", salt) + .update(`${sessionId}\0${messageId}`) + .digest("hex"); +} + +function emptyStore() { + return { + schemaVersion: 1, + observations: [], + pending: [], + droppedCount: 0, + truncated: false, + }; +} + +async function readStore(path) { + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw Object.assign(new Error("Usage attribution must be a regular file."), { + code: "USAGE_ATTRIBUTION_INVALID", + }); + } + if (metadata.size > MAX_FILE_BYTES) { + throw Object.assign(new Error("Usage attribution is too large."), { + code: "USAGE_ATTRIBUTION_TOO_LARGE", + }); + } + const value = JSON.parse(await readFile(path, "utf8")); + if (!value || value.schemaVersion !== 1 || !Array.isArray(value.observations)) + return { ...emptyStore(), droppedCount: 1, truncated: true }; + return { + schemaVersion: 1, + observations: value.observations, + pending: Array.isArray(value.pending) ? value.pending : [], + droppedCount: Number.isInteger(value.droppedCount) ? value.droppedCount : 0, + truncated: value.truncated === true, + }; + } catch (error) { + if (error?.code === "ENOENT") return emptyStore(); + if (error instanceof SyntaxError) { + throw Object.assign(new Error("Usage attribution is not valid JSON."), { + code: "USAGE_ATTRIBUTION_INVALID_JSON", + }); + } + throw error; + } +} + +function prune(store, now) { + const cutoff = now - RETENTION_MS; + let observations = store.observations.filter( + (item) => Date.parse(item.observedAt) >= cutoff, + ); + let droppedCount = store.droppedCount + (store.observations.length - observations.length); + if (observations.length > MAX_RECORDS) { + droppedCount += observations.length - MAX_RECORDS; + observations = observations.slice(observations.length - MAX_RECORDS); + } + let pending = store.pending; + if (pending.length > MAX_PENDING) { + droppedCount += pending.length - MAX_PENDING; + pending = pending.slice(pending.length - MAX_PENDING); + } + return { + schemaVersion: 1, + observations, + pending, + droppedCount, + truncated: store.truncated || droppedCount > store.droppedCount, + }; +} + +async function writeStore(path, store) { + const payload = `${JSON.stringify(store)}\n`; + if (Buffer.byteLength(payload) > MAX_FILE_BYTES) { + store.observations = store.observations.slice(-Math.floor(MAX_RECORDS / 2)); + store.truncated = true; + store.droppedCount += 1; + } + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + const temporary = join(directory, `.usage-attribution-${randomBytes(8).toString("hex")}.tmp`); + try { + await writeFile(temporary, `${JSON.stringify(store)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + await rename(temporary, path); + await chmod(path, 0o600); + } catch (error) { + try { + await unlink(temporary); + } catch { + /* original error is more useful */ + } + throw error; + } +} + +export async function upsertUsageObservation({ + settingsPath, + observation, + pending = false, + now = Date.now(), +} = {}) { + const path = resolveAttributionPath(settingsPath); + return withStateLock(settingsPath, async () => { + const normalized = validateUsageObservation(observation); + const store = prune(await readStore(path), now); + const list = pending ? store.pending : store.observations; + const index = list.findIndex((item) => item.eventKey === normalized.eventKey); + if (index >= 0) list[index] = normalized; + else list.push(normalized); + if (!pending) { + store.pending = store.pending.filter((item) => item.eventKey !== normalized.eventKey); + } + await writeStore(path, prune(store, now)); + }); +} + +export async function readUsageAttribution({ + settingsPath, + from, + to, + now = Date.now(), +} = {}) { + const path = resolveAttributionPath(settingsPath); + return withStateLock(settingsPath, async () => { + const store = prune(await readStore(path), now); + const start = from ? Date.parse(from) : 0; + const end = to ? Date.parse(to) : Number.POSITIVE_INFINITY; + const observations = store.observations.filter((item) => { + const at = Date.parse(item.observedAt); + return at >= start && at <= end; + }); + return { + observations, + coverage: { + firstObservedAt: observations[0]?.observedAt ?? null, + droppedCount: store.droppedCount, + truncated: store.truncated, + }, + }; + }); +} + +export function digestPriceSnapshot(snapshot) { + return createHash("sha256").update(JSON.stringify(snapshot ?? null)).digest("hex"); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 6695ec2..91e8f9e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -324,7 +324,7 @@ export default function App() {
Control plane {localOnly ? "local-only" : "not confirmed local"}
-
{paidAllowed ? "Paid models allowed" : "Verified free only"}
+
{paidAllowed ? (draftSettings?.paidEligibility === "configured-connections" ? "Configured paid connections" : "Legacy Paid") : "Verified free only"}
{dirty ? Unsaved changes : null} {dirty ? : null} diff --git a/src/ui/components/ModelTable.tsx b/src/ui/components/ModelTable.tsx index f385286..7d6b563 100644 --- a/src/ui/components/ModelTable.tsx +++ b/src/ui/components/ModelTable.tsx @@ -201,7 +201,9 @@ export function ModelTable({ Availability Evidence Inputs and roles - Cost + Connection + Access + Pricing Enabled @@ -254,11 +256,6 @@ export function ModelTable({ {available ? "Available" : "Unavailable"} -

- {reasons.length - ? reasons.join(" ") - : "Eligible under draft policy"} -

- + + + {model.provider ?? model.id.split("/")[0]} ·{" "} + {available ? "Available" : "Unavailable"} + +

+ {model.api && + typeof model.api === "object" && + "urlValid" in model.api && + model.api.urlValid === false + ? "Invalid endpoint" + : model.pricing?.reasons?.includes( + "public-price-route-mismatch", + ) + ? "Public rates do not match this endpoint" + : "Reported by OpenCode"} +

+ + + + + {reasons.length + ? reasons[0] + : "Eligible under draft policy"} + + {reasons.length > 1 ? ( +

+ {reasons.slice(1).join(" ")} +

+ ) : null} + +
Pricing evidence diff --git a/src/ui/components/RoleAssignments.tsx b/src/ui/components/RoleAssignments.tsx index cbcaa6e..655d6b5 100644 --- a/src/ui/components/RoleAssignments.tsx +++ b/src/ui/components/RoleAssignments.tsx @@ -8,6 +8,7 @@ import { ROLE_DEFINITIONS, selectRoleModel, setCostMode, + adoptConfiguredPaid, } from "../model-control.js"; import { Icon, Panel } from "./Primitives"; @@ -61,10 +62,23 @@ export function RoleAssignments({ >Paid - {settings.autoIncludeNewModels ? "Policy-following models join automatically when eligible. In Paid mode, newly discovered known-paid models can be enrolled and incur charges. Explicit disables remain off." : "New policy-following models stay off until explicitly enabled. Returning a model to Policy uses this setting."} + {settings.autoIncludeNewModels ? "Policy-following models join automatically when eligible. In Paid mode, newly discovered configured paid models can be enrolled. Explicit disables remain off." : "New policy-following models stay off until explicitly enabled. Returning a model to Policy uses this setting."} {settings.costPolicy === "known-cost" - ? "Paid-first automatic routing is enabled. Provider charges may apply; unknown-cost models stay blocked." + ? settings.paidEligibility === "configured-connections" + ? "Allow configured paid connections, including subscriptions. Cost estimates may be unavailable." + : "Legacy Paid policy still requires verified prices. Save the new Paid option to allow configured connections." : "Only models with independently verified free pricing can be routed."} + {settings.costPolicy === "known-cost" && settings.paidEligibility !== "configured-connections" ? ( +

+ Paid used to require verified public prices. Save Paid again to allow configured connections, including subscriptions, when estimates are unavailable. + +

+ ) : null}
{ROLE_DEFINITIONS.map((role) => ( @@ -120,7 +134,7 @@ export function RoleAssignments({ Allows one bounded return to the same code worker after a reviewer finds a concrete defect. It does not switch to another model.
-
{settings.costPolicy === "known-cost" ? "Known paid models allowed" : "Verified-free policy active"}Unknown or unverified pricing is always excluded from automatic routing.
+
{settings.costPolicy === "known-cost" ? (settings.paidEligibility === "configured-connections" ? "Configured paid connections allowed" : "Legacy verified-price Paid policy") : "Verified-free policy active"}{settings.costPolicy !== "known-cost" ? "Unknown or unverified pricing cannot authorize Free routing." : settings.paidEligibility === "configured-connections" ? "Missing estimates do not block a configured host route." : "Unknown public prices stay blocked until you adopt configured Paid access."}
{enabledModels.length === 0 ?

No models are enabled yet. Select a compatible model above or enable one in Models.

: null} ); diff --git a/src/ui/components/UsagePanel.tsx b/src/ui/components/UsagePanel.tsx index b8f5c98..71fc73d 100644 --- a/src/ui/components/UsagePanel.tsx +++ b/src/ui/components/UsagePanel.tsx @@ -8,11 +8,8 @@ const windows: Array<{ label: string; value: UsageWindow }> = [ { label: "All time", value: "all" }, ]; -function formatCount(value: number) { - return new Intl.NumberFormat(undefined, { notation: value >= 100_000 ? "compact" : "standard", maximumFractionDigits: 1 }).format(value); -} - -function formatCurrency(value: number) { +function formatCurrency(value: number | null | undefined) { + if (value === null || value === undefined) return "Not reported"; const maximumFractionDigits = value > 0 && value < 0.01 ? 4 : 2; return new Intl.NumberFormat(undefined, { style: "currency", @@ -22,6 +19,11 @@ function formatCurrency(value: number) { }).format(value); } +function formatCount(value: number | null | undefined) { + if (value === null || value === undefined) return "Not reported"; + return new Intl.NumberFormat(undefined, { notation: value >= 100_000 ? "compact" : "standard", maximumFractionDigits: 1 }).format(value); +} + function formatGeneratedAt(value?: string) { if (!value) return "Not loaded"; const date = new Date(value); @@ -61,7 +63,7 @@ export function UsagePanel({

Local OpenCode accounting

Usage

-

Aggregate token and recorded-cost history from OpenCode. Prompts and credentials are never read.

+

Tokens are usage. OpenCode-recorded cost is not a provider bill or subscription charge. Prompts and credentials are never read.

: null} {selectHint(role.key)} Selecting a disabled model explicitly enables it for routing. ))} diff --git a/src/ui/components/UsagePanel.tsx b/src/ui/components/UsagePanel.tsx index 71fc73d..0f3c8c7 100644 --- a/src/ui/components/UsagePanel.tsx +++ b/src/ui/components/UsagePanel.tsx @@ -1,4 +1,6 @@ import type { OpenCodeUsage, UsageWindow } from "../types"; +import { attributedUsageGroups, attributionCoverageWarning } from "../usage-view.js"; +import { billingLabel, evidenceSourceLabel } from "../model-control.js"; import { Button, Icon, Panel } from "./Primitives"; const windows: Array<{ label: string; value: UsageWindow }> = [ @@ -124,6 +126,19 @@ export function UsagePanel({ +
+

Captured connection usage

+

Partial coverage: these captured records are a subset of historical accounting above. Groups retain the billing declaration and binding at capture, separately for each currency. They are not additional charges.

+

Capture began: {usage.attributed?.coverage.firstObservedAt ?? "Not reported"}. Dropped records: {usage.attributed?.coverage.droppedCount ?? "Not reported"}. {usage.attributed?.coverage.truncated ? "Retention truncated this window." : ""} Pending: {usage.attributed?.coverage.pendingCount ?? "Not reported"}; Failed writes: {usage.attributed?.coverage.failedWriteCount ?? "Not reported"}.

+ {attributionCoverageWarning(usage.attributed?.coverage) ?

{attributionCoverageWarning(usage.attributed?.coverage)}

: null} + {attributedUsageGroups(usage.attributed?.observations).map((group, index) =>
+

{group.connectionLabel} · capture group {index + 1}

+

{billingLabel(group.billingKind)} · {evidenceSourceLabel(group.billingSource)} · {group.messages} captured messages

+

Recorded cost: {group.cost == null ? "Not reported" : `${group.cost.toLocaleString()} ${group.currency ?? "(currency not reported)"}`}. API estimate: unavailable. Provider charge: not reported. Quota: not reported.

+

Input: {formatCount(group.tokens.input)} · Output: {formatCount(group.tokens.output)} · Reasoning: {formatCount(group.tokens.reasoning)} · Cache read: {formatCount(group.tokens.cacheRead)} · Cache write: {formatCount(group.tokens.cacheWrite)}

+
)} + {!usage.attributed?.observations.length ?

No connection-attributed records in this window. Historical connection and billing: not reported.

: null} +
diff --git a/src/ui/editor-state.js b/src/ui/editor-state.js index 5c17188..017ae36 100644 --- a/src/ui/editor-state.js +++ b/src/ui/editor-state.js @@ -32,6 +32,7 @@ export function createEditor(raw, requestId = 0) { baseline: state.settings, draft: state.settings, baselineRevision: state.settingsRevision, + baselineConnectionRevision: state.connectionRevision, requestId, saving: null, }; @@ -53,6 +54,7 @@ export function receiveSnapshot(editor, raw, requestId) { baseline: state.settings, draft: state.settings, baselineRevision: state.settingsRevision, + baselineConnectionRevision: state.connectionRevision, } : {}), }; @@ -72,6 +74,7 @@ export function finishSave(editor, raw, requestId) { state, baseline: state.settings, baselineRevision: state.settingsRevision, + baselineConnectionRevision: state.connectionRevision, draft: mergeEdits(editor.saving.submitted, editor.draft, state.settings), requestId, saving: null, @@ -85,6 +88,7 @@ export function rebaseDraft(editor) { ...editor, baseline: editor.state.settings, baselineRevision: editor.state.settingsRevision, + baselineConnectionRevision: editor.state.connectionRevision, draft: mergeEdits(editor.baseline, editor.draft, editor.state.settings), }; } diff --git a/src/ui/model-control.js b/src/ui/model-control.js index dbd0cf1..ed6b6e5 100644 --- a/src/ui/model-control.js +++ b/src/ui/model-control.js @@ -84,6 +84,7 @@ export function modelCostClass(model) { } export function isModelCostAllowed(model, settings) { + if (connectionAccessReasons(model, settings).length) return false; const priceClass = modelCostClass(model); if (settings?.costPolicy !== "known-cost") return priceClass === "free"; if (settings?.paidEligibility === "configured-connections") @@ -282,7 +283,9 @@ export function normalizeState(raw) { ...system, opencode: system.opencode ?? system.openCode, }, - catalog, + catalog: catalog.map(model => ({...model, connection: (state.connections ?? []).find(connection => connection.providerId === (model.provider ?? model.id.split("/")[0])) ?? model.connection ?? null})), + connections: state.connections ?? [], + connectionRevision: state.connectionRevision ?? "", settings: normalizeSettings(state.settings, catalog), }; } @@ -404,7 +407,7 @@ export function modelEligibilityReasons( role, includeIntent = true, ) { - const reasons = []; + const reasons = connectionAccessReasons(model, settings); if (!model) return [ "Model unavailable in the catalog; refresh metadata or choose another model.", @@ -431,7 +434,12 @@ export function modelEligibilityReasons( reasons.push( "Free policy requires verified free access.", ); - if (role) reasons.push(...roleCapabilityReasons(model, role)); + if (role) { + reasons.push(...roleCapabilityReasons(model, role)); + const pin = settings?.roleConnections?.[role]; + if (settings?.roleAssignments?.[role] === model.id && model.connection && !pin) reasons.push("Connection selection required — select the current connection explicitly."); + if (settings?.roleAssignments?.[role] === model.id && pin && (!model.connection || pin.connectionId !== model.connection.id || pin.bindingRevision !== model.connection.bindingRevision)) reasons.push("Connection changed — review required. Select the current connection explicitly."); + } if (includeIntent && !modelIntentEnabled(settings, model.id)) reasons.push( modelSelection(settings, model.id) === "disabled" @@ -515,7 +523,8 @@ export function isRoleModelAssignable(model, settings, role) { export function isRoleModelEligible(model, settings, role) { return ( isRoleModelAssignable(model, settings, role) && - modelIntentEnabled(settings, model?.id) + modelIntentEnabled(settings, model?.id) && + modelEligibilityReasons(model, settings, role).length === 0 ); } @@ -526,6 +535,7 @@ export function selectRoleModel(settings, catalog, role, modelId) { return { ...settings, roleAssignments: { ...settings.roleAssignments, [role]: "auto" }, + roleConnections: {...settings.roleConnections, [role]: null}, }; } @@ -542,6 +552,7 @@ export function selectRoleModel(settings, catalog, role, modelId) { }, }, roleAssignments: { ...settings.roleAssignments, [role]: modelId }, + roleConnections: {...settings.roleConnections, [role]: model.connection ? {connectionId: model.connection.id, bindingRevision: model.connection.bindingRevision} : null}, }; } @@ -615,3 +626,25 @@ function stableStringify(value) { } return JSON.stringify(value); } + +export function billingLabel(kind) { + return ({subscription: "Subscription", "metered-api": "Metered API", prepaid: "Prepaid", local: "Local", free: "Free", unknown: "Billing not reported"})[kind] ?? "Billing not reported"; +} +export function evidenceSourceLabel(source) { + return ({host: "Reported by OpenCode", "provider-adapter": "Reported by provider", "user-declared": "Declared by you"})[source] ?? "Not reported"; +} +export function effectiveBilling(connection, settings) { + const declared = settings?.billingDeclarations?.[connection?.id]; + if (connection?.billing?.kind !== "unknown" && connection?.billing?.source !== "user-declared") return connection?.billing; + return declared?.bindingRevision === connection?.bindingRevision ? {...declared, observedAt: declared.declaredAt} : connection?.billing?.source === "user-declared" ? {kind: "unknown", source: "unknown"} : connection?.billing; +} +function connectionAccessReasons(model, settings) { + const reasons = []; + if (model?.api?.urlValid === false) reasons.push("Invalid endpoint; review the configured connection."); + if (model?.connection?.entitlement === "reported-revoked") reasons.push("Connection access revoked by the host."); + if (settings?.costPolicy !== "known-cost" && effectiveBilling(model?.connection, settings)?.kind === "subscription") reasons.push("Free policy requires verified free access; subscription access is paid."); + for (const reason of model?.blockedReasons ?? []) { + if (["connection-binding-changed", "connection-selection-required", "entitlement-revoked", "invalid-endpoint"].includes(reason)) reasons.push(({"connection-binding-changed": "Connection changed — review required.", "connection-selection-required": "Select a configured connection.", "entitlement-revoked": "Connection access revoked by the host.", "invalid-endpoint": "Invalid endpoint."})[reason]); + } + return reasons; +} diff --git a/src/ui/styles.css b/src/ui/styles.css index 3062f03..550f46d 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -2710,3 +2710,11 @@ textarea::placeholder { .models-panel td > .eligibility-reasons { grid-column: 1 / -1; } .models-panel td > .selection-control { grid-column: 2; } } + +.connection-card { border-top: 1px solid var(--border, #ddd); padding: 1rem 0; overflow-wrap: anywhere; } +.connection-card dl { display: grid; gap: .5rem; } +.connection-card dl div { display: grid; grid-template-columns: minmax(90px, 1fr) minmax(0, 2fr); gap: .75rem; } +.connection-card dd { margin: 0; } +.connections-panel select { width: 100%; min-width: 0; } +.attributed-usage { margin-top: 1.5rem; overflow-wrap: anywhere; } +.attributed-usage article { padding: 1rem 0; border-top: 1px solid var(--border, #ddd); } diff --git a/src/ui/types.ts b/src/ui/types.ts index 213fdd8..7866921 100644 --- a/src/ui/types.ts +++ b/src/ui/types.ts @@ -77,7 +77,7 @@ export interface RouterSettings { autoIncludeNewModels: boolean; roleAssignments: RoleAssignments; roleConnections?: Record; - billingDeclarations?: Record; + billingDeclarations?: Record; modelControls: Record; maxDelegationDepth: number; maxFallbacksPerAssignment: number; @@ -119,6 +119,8 @@ export interface ModelControlState { settings: RouterSettings; settingsRevision: string; catalogRevision: string; + connectionRevision: string; + connections: Connection[]; blockedRoles?: Record; rebased?: boolean; } @@ -289,11 +291,15 @@ export interface OpenCodeUsage { }; quota?: { status: string }; attributed?: { - observations: unknown[]; + observations: UsageObservation[]; coverage: { firstObservedAt: string | null; droppedCount: number; truncated: boolean; + failedWriteCount?: number; + partial?: boolean; + lastFailureCode?: "ATTRIBUTION_WRITE_FAILED" | "ATTRIBUTION_READ_FAILED" | null; + pendingCount?: number; }; }; caveats: string[]; @@ -320,6 +326,26 @@ export interface EditorState { baseline: RouterSettings; draft: RouterSettings; baselineRevision: string; + baselineConnectionRevision: string; requestId: number; saving: {requestId: number; submitted: RouterSettings} | null; } + +export type BillingKind = "subscription" | "metered-api" | "prepaid" | "local" | "free" | "unknown"; +export interface BillingDeclaration { kind: BillingKind; bindingRevision: string; source: "user-declared"; declaredAt: string } +export interface Connection { + id: string; providerId: string; bindingRevision: string; + authKind: "oauth" | "api-key" | "none" | "unknown"; + billing: {kind: BillingKind; source: string; observedAt: string | null}; + transportVisibility: string; inventoryObservedAt: string; + entitlement: string; + quota: {source: string; unit: string; limit: number | null; used: number | null; remaining: number | null; resetsAt: string | null; observedAt: string; expiresAt: string} | null; +} +export interface UsageObservation { + eventKey: string; observedAt: string; connectionId: string | null; bindingRevision: string | null; + billingKind: BillingKind; billingSource: string; + tokens: Omit; + recordedCost: {amount: number | null; currency: string | null} | null; + priceSnapshotId: string | null; + priceSnapshot?: {rates: Record; source: string; fetchedAt: string; expiresAt: string; semantics: null} | null; +} diff --git a/src/ui/usage-view.js b/src/ui/usage-view.js new file mode 100644 index 0000000..8e59941 --- /dev/null +++ b/src/ui/usage-view.js @@ -0,0 +1,22 @@ +// Historic labels use only the captured observation, never today's connection metadata. +export function attributedUsageGroups(observations = []) { + const groups = new Map(); + const connections = new Map(); + for (const observation of observations) { + const identity = observation.connectionId; + if (identity && !connections.has(identity)) connections.set(identity, connections.size + 1); + const key = JSON.stringify([identity, observation.bindingRevision, observation.billingKind, observation.billingSource, observation.recordedCost?.currency ?? null]); + if (!groups.has(key)) groups.set(key, {key, connectionLabel: identity ? `Captured connection ${connections.get(identity)}` : "Connection not attributed", billingKind: observation.billingKind, billingSource: observation.billingSource, currency: observation.recordedCost?.currency ?? null, messages: 0, cost: 0, tokens: {input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0}}); + const group = groups.get(key); + group.messages++; + group.cost = group.cost === null || observation.recordedCost?.amount == null ? null : group.cost + observation.recordedCost.amount; + for (const field of Object.keys(group.tokens)) group.tokens[field] = group.tokens[field] === null || observation.tokens?.[field] == null ? null : group.tokens[field] + observation.tokens[field]; + } + return [...groups.values()]; +} + +export function attributionCoverageWarning(coverage) { + if (coverage?.lastFailureCode === "ATTRIBUTION_READ_FAILED") return "Captured usage could not be read. Historical accounting remains separate; no zero usage was substituted."; + if (coverage?.lastFailureCode === "ATTRIBUTION_WRITE_FAILED" || coverage?.failedWriteCount > 0) return "Some captured usage could not be saved. This window has incomplete attribution."; + return coverage?.partial ? "Capture is incomplete: pending records or retention gaps affect this window." : ""; +} diff --git a/test/core/planner.test.js b/test/core/planner.test.js index c4f1796..d2cd947 100644 --- a/test/core/planner.test.js +++ b/test/core/planner.test.js @@ -373,6 +373,7 @@ test("automatic ranking applies cost preference before role score", () => { paid.id, ); assert.deepEqual(paidFirst.policy, { + paidEligibility: "verified-pricing", freeOnly: false, costPreference: "paid-first", costPolicy: "known-cost", diff --git a/test/core/schemas.test.js b/test/core/schemas.test.js index ddffc9b..d89795b 100644 --- a/test/core/schemas.test.js +++ b/test/core/schemas.test.js @@ -20,6 +20,7 @@ function readJson(relativePath) { test("benchmark contracts are strict versioned JSON Schemas", () => { const expectedVersions = new Map([ ["router-settings.schema.json", 4], + ["route-plan.schema.json", 2], ["model-catalog.schema.json", 2], ]); for (const file of SCHEMA_FILES) { diff --git a/test/core/usage-accounting.test.js b/test/core/usage-accounting.test.js index bfab471..0b7ebbc 100644 --- a/test/core/usage-accounting.test.js +++ b/test/core/usage-accounting.test.js @@ -58,3 +58,12 @@ test("observations keep billing provenance and reject mixed invalid numbers", () assert.equal(costLabel("opencode-recorded"), "OpenCode-recorded cost"); assert.equal(costLabel("unknown"), "Not reported"); }); + +test('estimates reject invalid rates, unsupported audio/tiering and unknown overlapping token semantics', () => { + const tokens = { input: 10, output: 2 }; + for (const rates of [{ input: -1, output: 1 }, { input: '1', output: 1 }, { input: 1, output: Infinity }, { input: 1, output: 1, input_audio: 2 }, { input: 1, output: 1, tiers: [] }]) { + assert.equal(estimateApiCost({ rates, tokens }).amount, null); + } + assert.equal(estimateApiCost({ rates: { input: 1, output: 1, reasoning: 1 }, tokens: { ...tokens, reasoning: 3 } }).amount, null); + assert.equal(estimateApiCost({ rates: { input: 1, output: 1 }, tokens, semantics: { audioRequired: true } }).amount, null); +}); diff --git a/test/mcp/snapshot-v3.test.js b/test/mcp/snapshot-v3.test.js index cbd5045..c5afba7 100644 --- a/test/mcp/snapshot-v3.test.js +++ b/test/mcp/snapshot-v3.test.js @@ -6,6 +6,7 @@ import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { ControlService } from "../../src/server/service.js"; import { createModelControlMcpServer } from "../../src/mcp/server.js"; +import { readConnectionSnapshot, writeConnectionSnapshot } from "../../src/server/connection-store.js"; import { publicFixture, liveModel } from "../fixtures/public-metadata.js"; test("an already connected MCP reloads panel discoveries and policy revisions before tools", async (t) => { const dir = await mkdtemp(join(tmpdir(), "omc-mcp-v3-")); @@ -144,4 +145,13 @@ test("MCP routing decisions include the exact snapshot revisions and bounded wor assert.equal(payload.policy.maxDelegationDepth, 1); assert.equal(payload.policy.maxFallbacksPerAssignment, 1); assert.equal(payload.policy.recursiveDelegation, false); + assert.equal(payload.connectionRevision, backend.getState().connectionRevision); + assert.equal(payload.assignments[0].connectionId, backend.getState().connections[0].id); + const settingsPath = join(dir, "settings.json"); + const snapshot = await readConnectionSnapshot({ settingsPath }); + snapshot.connections[0].entitlement = "reported-revoked"; + await writeConnectionSnapshot({ settingsPath, snapshot }); + const blocked = await client.callTool({ name: "route_task", arguments: { task: "Explain this function", modality: "text" } }); + assert.equal(blocked.isError, true); + assert.equal(blocked.structuredContent.error.code, "NO_ELIGIBLE_FREE_MODEL"); }); diff --git a/test/opencode/live-routing-v3.test.js b/test/opencode/live-routing-v3.test.js index c015e40..1ba8694 100644 --- a/test/opencode/live-routing-v3.test.js +++ b/test/opencode/live-routing-v3.test.js @@ -20,7 +20,7 @@ import { join } from "node:path"; const A = "opencode/ling-3.0-flash-fin-free", B = "opencode/nemotron-3.5-lightning-free"; -function fixture({ connections = [], connectionScopeId } = {}) { +function fixture({ connections = [], connectionScopeId, recordUsage = false, settingsPath } = {}) { const catalog = loadModelCatalog(); for (const m of catalog.models) m.api = { @@ -63,6 +63,7 @@ function fixture({ connections = [], connectionScopeId } = {}) { }, }; const hooks = createMediaRoutingHooks({ + recordUsage, settingsPath, loadPolicy: async () => ({ catalog, settings, @@ -550,6 +551,7 @@ test("usage attribution completes after a successful owned assistant message", a type: "message.updated", properties: { info: { + id: "assistant-1", sessionID: "child", role: "assistant", agent: "omc-code-worker", @@ -562,9 +564,15 @@ test("usage attribution completes after a successful owned assistant message", a }, }, }); + const step = { id: "assistant-tools", sessionID: "child", role: "assistant", agent: "omc-code-worker", parentID: output.message.id, time: { completed: Date.now() }, finish: "tool-calls", tokens: { input: 7, output: 2 }, cost: 0.1 }; + await hooks.event({ event: { type: "message.updated", properties: { info: step } } }); + await hooks.event({ event: { type: "message.updated", properties: { info: { ...step, tokens: { input: 999 } } } } }); + assert.equal((await hooks.dispose()).complete, true); await hooks.flushAttribution(); const attributed = await readUsageAttribution({ settingsPath }); - assert.equal(attributed.observations.length, 1); + assert.equal(attributed.observations.length, 2); + assert.equal(attributed.observations[1].tokens.input, 7); + assert.equal(attributed.coverage.pendingCount, 0); assert.equal(attributed.observations[0].tokens.input, 11); assert.equal(attributed.observations[0].recordedCost.amount, 0); await rm(directory, { recursive: true, force: true }); @@ -749,6 +757,44 @@ test("terminal background repair completion releases retention before a later di await f.dispatch(next); }); +test("terminal repair completion releases retention while attribution state lock is paused", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "omc-repair-accounting-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const settingsPath = join(directory, "settings.json"); + const f = fixture({ recordUsage: true, settingsPath }); + await reviewedWorker(f); + await beginTask(f, "repair", "omc-code-worker", "child"); + const repair = await f.turn(); + await finishTask(f, "repair", "child", "parent", true); + f.settings.roleAssignments["code-worker"] = B; + await f.dispatch(repair); + await f.hooks.flushAttribution(); + const { acquireFileLock } = await import("../../src/server/state-lock.js"); + const release = await acquireFileLock(`${settingsPath}.lock`); + t.after(release); + await Promise.race([f.hooks.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "repair-assistant", + sessionID: "child", + parentID: repair.message.id, + role: "assistant", + agent: "omc-code-worker", + finish: "stop", + time: { completed: Date.now() }, + }, + }, + }, + }), new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error("Accounting blocked completion")), 500); timer.unref(); })]); + const next = await f.turn(); + assert.equal(next.message.model.modelID, "nemotron-3.5-lightning-free"); + await f.dispatch(next); + await release(); + assert.equal((await f.hooks.dispose()).complete, true); +}); + test("review of W1 never turns an independent W2 resume into repair", async () => { for (const consumeRepair of [false, true]) { const f = fixture(); diff --git a/test/server/attribution-queue.test.js b/test/server/attribution-queue.test.js new file mode 100644 index 0000000..16cbeb2 --- /dev/null +++ b/test/server/attribution-queue.test.js @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createAttributionQueue } from '../../src/server/attribution-queue.js'; + +test('queue bounds outstanding work, releases settled work, flushes and recovers after failure', async () => { + let release; + const gate = new Promise(resolve => { release = resolve; }); + const states = [], persisted = [], ran = []; + const queue = createAttributionQueue({ limit: 2, onState: state => states.push(state), persist: async state => persisted.push(state) }); + assert.equal(queue.enqueue(async () => { await gate; throw new Error('secret payload'); }), true); + assert.equal(queue.enqueue(async () => { ran.push(2); }), true); + assert.equal(queue.enqueue(async () => ran.push(3)), false); + assert.equal((await queue.flush({ timeoutMs: 5 })).complete, false); + release(); + assert.equal((await queue.flush()).complete, true); + assert.deepEqual(ran, [2]); + assert.equal(queue.stats().pendingCount, 0); + assert.equal(queue.stats().failedWriteCount, 1); + assert.equal(queue.stats().droppedCount, 1); + assert.doesNotMatch(JSON.stringify(states), /secret/); + queue.enqueue(async () => ran.push(4)); + await queue.flush({ close: true }); + assert.deepEqual(ran, [2, 4]); + assert.equal(queue.enqueue(async () => {}), false); + assert.equal(persisted.at(-1).pendingCount, 0); +}); diff --git a/test/server/connection-policy-v4.test.js b/test/server/connection-policy-v4.test.js new file mode 100644 index 0000000..0a99be5 --- /dev/null +++ b/test/server/connection-policy-v4.test.js @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { ControlService } from "../../src/server/service.js"; +import { publicFixture, liveModel } from "../fixtures/public-metadata.js"; +import { readConnectionSnapshot, writeConnectionSnapshot } from "../../src/server/connection-store.js"; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), "omc-connection-policy-")); + const settingsPath = join(root, "settings.json"); + const service = await new ControlService({ + settingsPath, + discovery: async () => ({ installed: true, complete: true, models: [liveModel("new/model")], error: null }), + metadataFetch: async () => new Response(JSON.stringify(publicFixture([{ id: "new/model" }]))), + }).initialize(); + t.after(async () => { await service.close(); await rm(root, { recursive: true, force: true }); }); + return { service, settingsPath }; +} +const binding = (connection) => ({ connectionId: connection.id, bindingRevision: connection.bindingRevision }); +const revisions = (state) => ({ expectedSettingsRevision: state.settingsRevision, expectedConnectionRevision: state.connectionRevision, catalogRevision: state.catalogRevision }); + +test("billing changes require a connection revision and apply immediately without refresh", async (t) => { + const { service } = await fixture(t); + const state = service.getState(), connection = state.connections[0]; + const settings = { ...state.settings, billingDeclarations: { [connection.id]: { kind: "subscription", bindingRevision: connection.bindingRevision } } }; + await assert.rejects(service.updateSettings(settings, { expectedSettingsRevision: state.settingsRevision }), { code: "CONNECTION_REVISION_REQUIRED" }); + const saved = await service.updateSettings(settings, revisions(state)); + assert.equal(saved.connections[0].billing.kind, "subscription"); + assert.equal(saved.connections[0].billing.source, "user-declared"); + assert.throws(() => service.route({ task: "Explain this function", modality: "text" })); + const cleared = await service.updateSettings({ ...saved.settings, billingDeclarations: {} }, revisions(saved)); + assert.equal(cleared.connections[0].billing.kind, "unknown"); +}); + +test("planner rejects changed pins and exposes exact connection on valid paid plans", async (t) => { + const { service, settingsPath } = await fixture(t); + let state = service.getState(), connection = state.connections[0]; + state = await service.updateSettings({ ...state.settings, costPolicy: "known-cost", paidEligibility: "configured-connections", roleAssignments: { ...state.settings.roleAssignments, orchestrator: "new/model" }, roleConnections: { ...state.settings.roleConnections, orchestrator: binding(connection) } }, revisions(state)); + const plan = service.route({ task: "Explain this function", modality: "text" }); + assert.equal(plan.assignments[0].connectionId, connection.id); + assert.equal(plan.connectionRevision, state.connectionRevision); + const snapshot = await readConnectionSnapshot({ settingsPath }); + snapshot.connections[0].bindingRevision = "f".repeat(32); + await writeConnectionSnapshot({ settingsPath, snapshot }); + await service.reloadSettings(); + assert.ok(service.getState().blockedRoles.orchestrator.includes("connection-binding-changed")); + assert.throws(() => service.route({ task: "Explain this function", modality: "text" }), { code: "INVALID_ROLE_ASSIGNMENT" }); + await assert.rejects(service.updateSettings({ ...state.settings, billingDeclarations: { [connection.id]: { kind: "subscription", bindingRevision: connection.bindingRevision } } }, revisions(state)), { code: "CONNECTION_CONFLICT" }); +}); + +test("binding-only edits are validated and unrelated saves retain new connections", async (t) => { + const { service } = await fixture(t); + const state = service.getState(); + await assert.rejects(service.updateSettings({ ...state.settings, roleConnections: { ...state.settings.roleConnections, orchestrator: { connectionId: "e".repeat(32), bindingRevision: "f".repeat(32) } } }, revisions(state)), { code: "CONNECTION_CONFLICT" }); + service.discovery = async () => ({ installed: true, complete: true, models: [liveModel("new/model"), liveModel("second/model")] }); + await service.refreshCatalog(); + const saved = await service.updateSettings({ ...state.settings, maxDelegationDepth: 0 }, revisions(state)); + assert.equal(saved.connections.length, 2); +}); + +test("failed discovery preserves cached connection bindings without renewing observation time", async (t) => { + const { service } = await fixture(t); + const before = service.getState().connections; + service.discovery = async () => { throw new Error("offline"); }; + await service.refreshCatalog(); + assert.deepEqual(service.getState().connections, before); +}); + +test("new explicit pins cannot bypass or remove their exact connection binding", async (t) => { + const { service } = await fixture(t); + let state = service.getState(); + const settings = { ...state.settings, roleAssignments: { ...state.settings.roleAssignments, orchestrator: "new/model" } }; + await assert.rejects(service.updateSettings(settings, { expectedSettingsRevision: state.settingsRevision }), { code: "CONNECTION_REVISION_REQUIRED" }); + await assert.rejects(service.updateSettings(settings, revisions(state)), { code: "CONNECTION_CONFLICT" }); + state = await service.updateSettings({ ...settings, roleConnections: { ...settings.roleConnections, orchestrator: binding(state.connections[0]) } }, revisions(state)); + await assert.rejects(service.updateSettings({ ...state.settings, roleConnections: { ...state.settings.roleConnections, orchestrator: null } }, revisions(state)), { code: "CONNECTION_CONFLICT" }); +}); diff --git a/test/server/production-entry.test.js b/test/server/production-entry.test.js index bb8022a..15d8549 100644 --- a/test/server/production-entry.test.js +++ b/test/server/production-entry.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, readFile, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -86,6 +86,19 @@ test("normal CLI ignores NODE_ENV=development and never imports Vite", async (t) "utf8", ); + // This test exercises CLI startup, not host discovery. Keep discovery inside + // the fixture so user plugins, credentials, and network cannot affect it. + const fixtureBin = join(temporaryDirectory, "bin"); + const invocationPath = join(temporaryDirectory, "host-invocations.jsonl"); + await mkdir(fixtureBin); + await writeFile(join(fixtureBin, "opencode"), `#!${process.execPath} +import { appendFileSync } from "node:fs"; +const args = process.argv.slice(2); +appendFileSync(${JSON.stringify(invocationPath)}, JSON.stringify(args) + "\\n"); +if (args[0] === "--version") process.stdout.write("1.18.28\\n"); +else if (args[0] !== "models") process.exit(2); +`, { mode: 0o700 }); + const port = await reserveLoopbackPort(); let stdout = ""; let stderr = ""; @@ -99,7 +112,12 @@ test("normal CLI ignores NODE_ENV=development and never imports Vite", async (t) ], { env: { - ...process.env, + PATH: `${fixtureBin}:${process.env.PATH}`, + HOME: temporaryDirectory, + XDG_CONFIG_HOME: join(temporaryDirectory, "xdg-config"), + XDG_CACHE_HOME: join(temporaryDirectory, "xdg-cache"), + XDG_DATA_HOME: join(temporaryDirectory, "xdg-data"), + OPENCODE_AUTH_CONTENT: "{}", NODE_ENV: "development", OMC_CONFIG_DIR: join(temporaryDirectory, "config"), OMC_PORT: String(port), @@ -127,4 +145,7 @@ test("normal CLI ignores NODE_ENV=development and never imports Vite", async (t) assert.equal(response.status, 200); assert.match(response.headers.get("content-security-policy") ?? "", /default-src/); assert.doesNotMatch(stdout + stderr, /VITE_IMPORT_FORBIDDEN/); + const invocations = (await readFile(invocationPath, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + assert.ok(invocations.some(args => args[0] === "models")); + assert.ok(invocations.every(args => ["--version", "models"].includes(args[0]))); }); diff --git a/test/server/service-v3.test.js b/test/server/service-v3.test.js index 12be7c6..1e5147c 100644 --- a/test/server/service-v3.test.js +++ b/test/server/service-v3.test.js @@ -110,8 +110,9 @@ test("blocked saved pins survive refresh and unrelated saves; newly edited block ...state.settings.roleAssignments, orchestrator: "new/model", }, + roleConnections: { ...state.settings.roleConnections, orchestrator: { connectionId: state.connections[0].id, bindingRevision: state.connections[0].bindingRevision } }, }, - { expectedSettingsRevision: state.settingsRevision }, + { expectedSettingsRevision: state.settingsRevision, expectedConnectionRevision: state.connectionRevision }, ); service.metadataFetch = async () => new Response( @@ -133,8 +134,9 @@ test("blocked saved pins survive refresh and unrelated saves; newly edited block ...state.settings.roleAssignments, reviewer: "new/model", }, + roleConnections: { ...state.settings.roleConnections, reviewer: { connectionId: state.connections[0].id, bindingRevision: state.connections[0].bindingRevision } }, }, - { expectedSettingsRevision: state.settingsRevision }, + { expectedSettingsRevision: state.settingsRevision, expectedConnectionRevision: state.connectionRevision }, ), (e) => e.statusCode === 409 && e.code === "SELECTION_CONFLICT", ); diff --git a/test/server/service.test.js b/test/server/service.test.js index 3d941ad..efac09d 100644 --- a/test/server/service.test.js +++ b/test/server/service.test.js @@ -219,7 +219,9 @@ test("a complete catalog snapshot preserves enabled plugin models across a parti settings.costPolicy = "known-cost"; settings.modelControls[pluginModel.id] = {selection:"enabled"}; settings.roleAssignments["code-worker"] = pluginModel.id; - await first.updateSettings(settings, {expectedSettingsRevision:first.getState().settingsRevision}); + const selectedConnection = first.getState().connections.find(connection => connection.providerId === pluginModel.id.split("/")[0]); + settings.roleConnections["code-worker"] = { connectionId: selectedConnection.id, bindingRevision: selectedConnection.bindingRevision }; + await first.updateSettings(settings, {expectedSettingsRevision:first.getState().settingsRevision, expectedConnectionRevision:first.getState().connectionRevision}); assert.equal((await stat(snapshotPath)).mode & 0o777, 0o600); const second = await new ControlService({ metadataFetch:publicMetadataFetch, settingsPath, discovery: incompleteDiscovery }).initialize(); diff --git a/test/server/usage-attribution-store.test.js b/test/server/usage-attribution-store.test.js index a175e98..870b80e 100644 --- a/test/server/usage-attribution-store.test.js +++ b/test/server/usage-attribution-store.test.js @@ -73,3 +73,59 @@ test("concurrent attribution upserts serialize and retain both records", async ( const read = await readUsageAttribution({ settingsPath }); assert.equal(read.observations.length, 2); }); + +test('simultaneous startup publishes a single stable private salt', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'omc-salt-race-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const settingsPath = join(directory, 'settings.json'); + const salts = await Promise.all(Array.from({ length: 20 }, () => readOrCreateAttributionSalt(settingsPath))); + assert.equal(new Set(salts.map(salt => salt.toString('hex'))).size, 1); +}); + +test('read rejects malformed rows without echoing payload and prunes old pending rows', async (t) => { + const { writeFile } = await import('node:fs/promises'); + const directory = await mkdtemp(join(tmpdir(), 'omc-attr-validation-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const settingsPath = join(directory, 'settings.json'); + await writeFile(join(directory, 'usage-attribution.json'), JSON.stringify({ schemaVersion: 1, observations: [{ eventKey: 'secret' }] })); + await assert.rejects(readUsageAttribution({ settingsPath }), error => !error.message.includes('secret')); + const row = { eventKey: 'a'.repeat(64), observedAt: '2025-01-01T00:00:00Z', connectionId: null, bindingRevision: null, billingKind: 'unknown', billingSource: 'unknown', tokens: {}, recordedCost: null, priceSnapshotId: null }; + await writeFile(join(directory, 'usage-attribution.json'), JSON.stringify({ schemaVersion: 1, observations: [], pending: [row] })); + const result = await readUsageAttribution({ settingsPath, now: Date.parse('2026-09-08') }); + assert.equal(result.coverage.pendingCount, 0); + assert.equal(result.coverage.droppedCount, 1); + assert.equal(result.coverage.partial, true); +}); + +test('write failure diagnostics remain visible after recovery without leaking the error', async (t) => { + const { createAttributionQueue } = await import('../../src/server/attribution-queue.js'); + const { setAttributionDiagnostics, persistAttributionDiagnostics } = await import('../../src/server/usage-attribution-store.js'); + const directory = await mkdtemp(join(tmpdir(), 'omc-attr-recovery-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const settingsPath = join(directory, 'settings.json'); + const queue = createAttributionQueue({ onState: state => setAttributionDiagnostics(settingsPath, state), persist: state => persistAttributionDiagnostics(settingsPath, state) }); + queue.enqueue(async () => { throw new Error('private prompt /secret/path'); }); + await queue.flush(); + queue.enqueue(async () => {}); + await queue.flush(); + const { coverage } = await readUsageAttribution({ settingsPath }); + assert.equal(coverage.failedWriteCount, 1); + assert.equal(coverage.partial, true); + assert.equal(coverage.lastFailureCode, 'ATTRIBUTION_WRITE_FAILED'); + assert.doesNotMatch(JSON.stringify(coverage), /private|secret/); +}); + +test('historical rate evidence is immutable, sanitized, and independent of current rates', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'omc-attr-price-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const settingsPath = join(directory, 'settings.json'); + const observation = { eventKey: 'b'.repeat(64), observedAt: new Date().toISOString(), connectionId: null, bindingRevision: null, billingKind: 'unknown', billingSource: 'unknown', tokens: { input: 10 }, recordedCost: null, priceSnapshotId: null, priceSnapshot: { rates: { input: 1, output: 2 }, source: 'https://models.dev/api.json', fetchedAt: new Date().toISOString(), expiresAt: new Date(Date.now()+86400000).toISOString(), secret: 'must not persist' } }; + await upsertUsageObservation({ settingsPath, observation }); + observation.priceSnapshot.rates.input = 99; + await upsertUsageObservation({ settingsPath, observation }); + const row = (await readUsageAttribution({ settingsPath })).observations[0]; + assert.equal(row.priceSnapshot.rates.input, 1); + assert.equal(row.priceSnapshot.semantics, null); + assert.match(row.priceSnapshotId, /^[a-f0-9]{64}$/); + assert.doesNotMatch(JSON.stringify(row), /must not persist/); +}); diff --git a/test/ui/connections.test.js b/test/ui/connections.test.js new file mode 100644 index 0000000..35aa6e2 --- /dev/null +++ b/test/ui/connections.test.js @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {normalizeState, selectRoleModel, modelEligibilityReasons, effectiveBilling, toggleEnabledModel} from "../../src/ui/model-control.js"; +import {createEditor, editDraft, receiveSnapshot, finishSave, startSave} from "../../src/ui/editor-state.js"; +import {attributedUsageGroups, attributionCoverageWarning} from "../../src/ui/usage-view.js"; +const connection = {id: "private-id", providerId: "newvendor", bindingRevision: "r1", billing: {kind: "unknown", source: "unknown"}, entitlement: "not-reported"}; +const raw = {settingsRevision: "s1", connectionRevision: "c1", connections: [connection], settings: {costPolicy: "known-cost", paidEligibility: "configured-connections"}, catalog: [{id:"newvendor/model", available:true, modalities:{input:["text"],output:["text"]}, roles:{reviewer:1}, access:["read"], pricingClass:"unknown"}]}; +test("pins capture exact slot binding and retain old pins when the binding changes", () => { + const state=normalizeState(raw); + const selected=selectRoleModel(state.settings,state.catalog,"reviewer","newvendor/model"); + assert.deepEqual(selected.roleConnections.reviewer,{connectionId:connection.id,bindingRevision:"r1"}); + const changed={...state.catalog[0],connection:{...connection,bindingRevision:"r2"}}; + assert.ok(modelEligibilityReasons(changed,selected,"reviewer").some(x=>x.includes("Connection changed"))); + assert.equal(selected.roleConnections.reviewer.bindingRevision,"r1"); + assert.equal(selectRoleModel(selected,[changed],"reviewer","auto").roleConnections.reviewer,null); +}); +test("declarations never infer API billing from auth, cannot replace host evidence and expire with bindings",()=>{ + const settings={billingDeclarations:{[connection.id]:{kind:"subscription",source:"user-declared",bindingRevision:"r1",declaredAt:"date"}}}; + assert.equal(effectiveBilling({...connection,authKind:"api-key"},{}).kind,"unknown"); + assert.equal(effectiveBilling(connection,settings).source,"user-declared"); + assert.equal(effectiveBilling({...connection,bindingRevision:"r2"},settings).kind,"unknown"); + assert.equal(effectiveBilling({...connection,billing:{kind:"prepaid",source:"host"}},settings).kind,"prepaid"); +}); +test("connection baseline survives dirty refresh and advances only with successful save",()=>{ + let editor=createEditor(raw); + editor=editDraft(editor,{...editor.draft,billingDeclarations:{[connection.id]:{kind:"subscription",bindingRevision:"r1"}}}); + editor=receiveSnapshot(editor,{...raw,connectionRevision:"c2"},2); + assert.equal(editor.baselineConnectionRevision,"c1"); + editor=startSave(editor,3); editor=finishSave(editor,{...raw,connectionRevision:"c3",settings:editor.draft},3); + assert.equal(editor.baselineConnectionRevision,"c3"); +}); +test("captured usage separates bindings billing currencies, hides identifiers and preserves null versus zero",()=>{ + const observation={connectionId:"secret-id",bindingRevision:"r1",billingKind:"subscription",billingSource:"user-declared",tokens:{input:0,output:null,reasoning:null,cacheRead:null,cacheWrite:null},recordedCost:{amount:0,currency:"USD"}}; + const groups=attributedUsageGroups([observation,{...observation,recordedCost:{amount:2,currency:"EUR"}},{...observation,bindingRevision:"r2",billingKind:"metered-api",recordedCost:null}]); + assert.equal(groups.length,3); assert.equal(groups[0].cost,0); assert.equal(groups[0].tokens.output,null); assert.equal(groups[2].cost,null); assert.equal(groups[0].connectionLabel,"Captured connection 1"); +}); +test("revoked evidence and a subscription declaration block eligibility without preventing explicit disable",()=>{ + const state=normalizeState(raw); + const revoked={...state.catalog[0],connection:{...connection,entitlement:"reported-revoked"}}; + assert.ok(modelEligibilityReasons(revoked,state.settings,"reviewer").some(x=>x.includes("revoked"))); + assert.equal(toggleEnabledModel(state.settings,revoked.id,false).modelControls[revoked.id].selection,"disabled"); + const subscription={...state.settings,costPolicy:"free-only",billingDeclarations:{[connection.id]:{kind:"subscription",source:"user-declared",bindingRevision:"r1"}}}; + assert.ok(modelEligibilityReasons({...state.catalog[0],pricingClass:"free"},subscription,"reviewer").some(x=>x.includes("subscription access is paid"))); +}); + +test("attribution coverage exposes actual failed-write and failed-read diagnostics",()=>{ + assert.match(attributionCoverageWarning({failedWriteCount:3,partial:true,lastFailureCode:"ATTRIBUTION_WRITE_FAILED"}),/could not be saved/); + assert.match(attributionCoverageWarning({failedWriteCount:0,partial:true,lastFailureCode:"ATTRIBUTION_READ_FAILED"}),/could not be read/); + assert.match(attributionCoverageWarning({failedWriteCount:0,pendingCount:1,partial:true,lastFailureCode:null}),/Capture is incomplete/); + assert.equal(attributionCoverageWarning({failedWriteCount:0,pendingCount:0,partial:false,lastFailureCode:null}),""); + const groups=attributedUsageGroups([{connectionId:null,bindingRevision:null,billingKind:"unknown",billingSource:"unknown",tokens:{},recordedCost:{amount:null,currency:"USD"},priceSnapshot:null}]); + assert.equal(groups[0].cost,null); +}); From 764eeaea783f16edc4ed38c2628e87d36ddf0ccf Mon Sep 17 00:00:00 2001 From: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:31:05 -0400 Subject: [PATCH 13/15] Bind acceptance role edits to the current connection revision Signed-off-by: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> --- scripts/host-acceptance.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/host-acceptance.mjs b/scripts/host-acceptance.mjs index adcd24a..d5f9b18 100644 --- a/scripts/host-acceptance.mjs +++ b/scripts/host-acceptance.mjs @@ -478,9 +478,17 @@ export default async () => ({ "chat.message": async (_input, output) => { ...state.settings.roleAssignments, "code-worker": "omctest/c", }, + roleConnections: { + ...state.settings.roleConnections, + "code-worker": { + connectionId: state.connections.find(connection => connection.providerId === "omctest").id, + bindingRevision: state.connections.find(connection => connection.providerId === "omctest").bindingRevision, + }, + }, }, { expectedSettingsRevision: state.settingsRevision, + expectedConnectionRevision: state.connectionRevision, catalogRevision: state.catalogRevision, }, ); From 3ef4eda9797e9f4717cb4cd3a2601910ae40aa9a Mon Sep 17 00:00:00 2001 From: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:36:56 -0400 Subject: [PATCH 14/15] Preserve a valid disabled choice in the legacy upgrade fixture Signed-off-by: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> --- scripts/package-acceptance.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/package-acceptance.mjs b/scripts/package-acceptance.mjs index deb2160..37f53b0 100644 --- a/scripts/package-acceptance.mjs +++ b/scripts/package-acceptance.mjs @@ -421,7 +421,11 @@ try { if (scenario.schema === 3) legacySettings.autoIncludeNewModels = scenario.autoInclude; legacySettings.roleAssignments.reviewer = "absent/explicit-pin"; - legacySettings.modelControls["absent/disabled-model"] = + // 0.2.1 discards unknown control identities during its own Connect. Use a + // catalog identity there; 0.3.0 must also preserve absent disabled identities. + const disabledId = scenario.schema === 3 ? "absent/disabled-model" : Object.keys(legacySettings.modelControls)[0]; + assert.ok(disabledId, `${label}: baseline must expose a model to disable`); + legacySettings.modelControls[disabledId] = scenario.schema === 3 ? { selection: "disabled", available: false } : { enabled: false, available: false }; @@ -447,6 +451,8 @@ try { scenario.surface, ); const legacySaved = await readFile(settingsPath, "utf8"); + assert.ok(JSON.parse(legacySaved).modelControls[disabledId], `${label}: disabled choice must exist before migration`); + if (scenario.schema === 3) assert.equal(JSON.parse(legacySaved).roleAssignments.reviewer, "absent/explicit-pin"); const beforeUpdate = await readFile(env.OMC_OPENCODE_CONFIG_PATH, "utf8"); assert.equal((await integrate("status")).code, "UPDATE_REQUIRED"); // Merely observing an outdated connection must never install a new surface. @@ -487,7 +493,7 @@ try { migrated.modelControls, JSON.parse(legacySaved).modelControls, ); - assert.deepEqual(migrated.modelControls["absent/disabled-model"], { + assert.deepEqual(migrated.modelControls[disabledId], { selection: "disabled", available: false, }); From 33403f40f0a5aca4bea80db6f8ce47b0e42201d3 Mon Sep 17 00:00:00 2001 From: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:43:49 -0400 Subject: [PATCH 15/15] Validate legacy upgrades against their actual saved policy Signed-off-by: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> --- scripts/package-acceptance.mjs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/scripts/package-acceptance.mjs b/scripts/package-acceptance.mjs index 37f53b0..301169a 100644 --- a/scripts/package-acceptance.mjs +++ b/scripts/package-acceptance.mjs @@ -420,15 +420,16 @@ try { legacySettings.costPreference = scenario.paid ? "paid-first" : "free-first"; if (scenario.schema === 3) legacySettings.autoIncludeNewModels = scenario.autoInclude; - legacySettings.roleAssignments.reviewer = "absent/explicit-pin"; - // 0.2.1 discards unknown control identities during its own Connect. Use a - // catalog identity there; 0.3.0 must also preserve absent disabled identities. - const disabledId = scenario.schema === 3 ? "absent/disabled-model" : Object.keys(legacySettings.modelControls)[0]; + if (scenario.schema === 3) legacySettings.roleAssignments.reviewer = "absent/explicit-pin"; + // 0.2.1 rejects unknown identities. Keep its fixture valid; 0.3.0 must also + // preserve absent disabled identities and requested pins. + const disabledId = scenario.schema === 3 ? "absent/disabled-model" : Object.entries(legacySettings.modelControls).find(([id, control]) => control.enabled && !Object.values(legacySettings.roleAssignments).includes(id))?.[0]; assert.ok(disabledId, `${label}: baseline must expose a model to disable`); legacySettings.modelControls[disabledId] = scenario.schema === 3 ? { selection: "disabled", available: false } : { enabled: false, available: false }; + assert.deepEqual(oldCore.validateSettings(legacySettings).modelControls[disabledId], legacySettings.modelControls[disabledId]); const settingsPath = join(env.OMC_CONFIG_DIR, "settings.json"); await writeFile( settingsPath, @@ -451,7 +452,9 @@ try { scenario.surface, ); const legacySaved = await readFile(settingsPath, "utf8"); - assert.ok(JSON.parse(legacySaved).modelControls[disabledId], `${label}: disabled choice must exist before migration`); + const savedDisabled = JSON.parse(legacySaved).modelControls[disabledId]; + if (scenario.schema === 3) assert.deepEqual(savedDisabled, legacySettings.modelControls[disabledId]); + else assert.equal(savedDisabled?.enabled, false, `${label}: old Connect must retain the disabled choice`); if (scenario.schema === 3) assert.equal(JSON.parse(legacySaved).roleAssignments.reviewer, "absent/explicit-pin"); const beforeUpdate = await readFile(env.OMC_OPENCODE_CONFIG_PATH, "utf8"); assert.equal((await integrate("status")).code, "UPDATE_REQUIRED"); @@ -495,7 +498,7 @@ try { ); assert.deepEqual(migrated.modelControls[disabledId], { selection: "disabled", - available: false, + available: savedDisabled.available, }); const migrations = (await readdir(env.OMC_CONFIG_DIR)).filter((name) => name.startsWith(`settings.json.v${scenario.schema}.backup-`),