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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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-`), From 060320f90f23f293fff21134dfde59d062e72908 Mon Sep 17 00:00:00 2001 From: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:27:23 -0400 Subject: [PATCH 16/16] Guard mixed provider route bindings before release Signed-off-by: Chris <51251284+BitL8-ByteShort@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 20 ++++--- docs/opencode-integration.md | 4 +- ...-connection-billing-usage-execution-log.md | 6 ++ docs/support-matrix.md | 20 ++++--- src/core/connections.js | 15 ++++- src/opencode/connection-observer.js | 3 +- test/core/connections.test.js | 47 +++++++++++++++ test/opencode/live-routing-v3.test.js | 60 +++++++++++++++++++ 9 files changed, 156 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 248f5fe..b7c76dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to OpenCode Model Control are recorded here. The project fol - Show configured connection billing declarations, evidence sources, exact role bindings, and separate captured usage by billing kind and currency. Connection edits require current revisions; drafts survive conflicts. - Reload connection evidence in planner and MCP routes. Bound attribution work and retention, preserve assistant-message history and price snapshots, and flush on host disposal. - Verify upgrades from the exact public 0.3.0 package under both Free and legacy Paid policy, including private backups and guarded update/recovery. +- Bind mixed-endpoint providers to their per-model route mapping, including endpoint/SDK swaps. Changed connections invalidate pins and retained repairs and discard stale quota. - Managed integration surface version 3; an explicit Connect/update and OpenCode restart is required for plugin behavior changes. ## 0.3.0 diff --git a/README.md b/README.md index c8a3c7b..189df98 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.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. +> 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. ## What it does @@ -17,8 +17,8 @@ The running app is authoritative for model names, availability, pricing evidence - Separates discovery, saved inclusion intent, and effective eligibility. Auto-include defaults on and follows the saved cost policy; explicit disables remain authoritative. - Provides a **Free / Paid** preference: - **Free** permits only independently verified zero-cost models. - - **Paid** permits verified free and known paid models, prioritizing paid models for compatible automatic assignments. - - Unknown or ambiguous pricing is always shown as **Unknown — blocked**. + - **Paid** permits eligible configured OpenCode connections, including subscription and API access when public prices are unavailable. Existing Paid settings retain verified-price behavior until you explicitly adopt configured Paid access. + - Unknown prices remain unreported. They block Free and legacy verified-price Paid routing, but do not by themselves block configured Paid access. - Resolves the orchestrator, code, vision, and review roles from current eligible models without a fixed free-model roster or new quality ranking. - Transparently sends media-only analysis through the saved compatible, tool-free vision worker. Only explicit user-authored text classified as a code change keeps Omc-Router active for a seamless vision-to-code-to-review workflow. - Automatically routes approved code changes through a code worker, an independent reviewer, and at most one repair pass without requiring `@` mentions. @@ -108,7 +108,7 @@ The connector writes absolute Node and package CLI paths, so a source checkout d ### Direct GitHub release artifact -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. +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.4.0 publication and verification procedure. ## What “Update available models” means (0.4.0) @@ -129,16 +129,22 @@ Catalog state is deliberately split into four concepts: ## Free-first, Paid-first, and pricing evidence -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). 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 pricing evidence is unknown. It cannot authorize Free or legacy verified-price Paid routing; configured Paid routing instead requires an eligible host connection. Complete positive CLI evidence can establish `reported-paid` when independent evidence does not contradict it; CLI zero cannot establish free. 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. -**Automatically include new models** defaults on. A model with `selection: "policy"` (including an absent control) follows that setting and the saved Free/Paid policy. Free permits current verified-free evidence only; Paid permits known-paid and verified-free models and prefers paid after hard gates. Saving Paid with auto-include on authorizes future eligible known-paid models without a separate click for every new model. Turning auto-include off excludes policy-following models; explicit enables still apply. An explicit disable always wins. An enable or role pin cannot bypass unknown/expired pricing, availability, capabilities, or cost policy. +**Automatically include new models** defaults on. A model with `selection: "policy"` (including an absent control) follows that setting and the saved Free/Paid policy. Free permits current verified-free evidence only. Configured Paid permits eligible host connections without requiring a public estimate. Saving that Paid mode with auto-include on authorizes future eligible configured models without a separate click for every new model. Turning auto-include off excludes policy-following models; explicit enables still apply. An explicit disable always wins. An enable or role pin cannot bypass availability, capabilities, connection binding, or the selected policy. Free and legacy verified-price Paid still require current pricing evidence. Selecting a compatible role model can explicitly enable it in the draft; selecting Automatic changes the role choice without writing inferred model enables. **Save changes** commits user intent. Refresh never adds inferred controls or rewrites saved intent. Selecting **Paid** can incur charges under the active OpenCode provider account. Model Control does not enforce provider-side budgets. +The **Configured connections** panel separates billing from public prices. You can declare subscription, metered API, prepaid, local, or free billing for an otherwise unreported connection; this is labeled as your declaration, not verified entitlement. Host or adapter evidence takes precedence. Explicit role assignments bind to the selected connection. Changes exposed by OpenCode require review; credential changes the host does not expose cannot be detected. + +For a custom provider that maps models to different endpoints or SDK adapters, any change to that model-to-route mapping requires reviewing its saved pins and billing declaration. Adding a model to such a mixed provider can also invalidate the binding conservatively. New models on an unchanged shared endpoint retain the existing binding. Quota from an old binding is discarded. + +Captured usage retains the billing label and binding observed at dispatch. It separates tokens and OpenCode-recorded costs by currency, preserves missing values, and reports partial coverage. Subscription quota is shown only when reported; API-equivalent estimates and actual charges are not invented from unavailable token semantics. + The model detail view preserves OpenCode's effective report separately from supplemental public metadata: input/output modalities, tool calls, reasoning and reasoning options, structured output, temperature, attachments, interleaving, and context/input/output limits. Unknown (`null` or absent) is distinct from an explicit `false`; context/input/output limits are positive integers when known. Supplemental data can explain a model but cannot expand OpenCode's effective modalities or tool access. Exact API mismatches discard supplemental evidence. Capability-derived role profiles refresh without changing curated restrictions, ranking, or benchmark qualification. ## Seamless routing boundaries @@ -155,7 +161,7 @@ A vision-worker assignment is eligible only when OpenCode reports that the exact All four stable managed agents are installed without baked-in `model` fields: `omc-router`, `omc-code-worker`, `omc-vision-worker`, and `omc-reviewer`. The local plugin reads coherent saved settings/catalog state for every owned turn, including text, specialist tasks, and ordinary resumed tasks. It intersects eligible catalog models with the current OpenCode instance's loaded provider inventory. Saving A → B takes effect on the next owned turn when B is already loaded; ordinary policy changes do not rewrite config or require reconnecting. -At `chat.params`, the plugin rechecks current policy, price expiry, loaded inventory, exact provider/model/API identity, endpoint/transport, effective capabilities, and effective rates before inference. Missing or corrupt saved state, disabled or unavailable selections, incompatible effective metadata, and unknown pricing fail closed. An explicit pin is never silently replaced. Unrelated OpenCode agents keep their own selections. +At `chat.params`, the plugin rechecks current policy, price expiry, loaded inventory, exact provider/model/API identity, endpoint/transport, effective capabilities, and effective rates before inference. Missing or corrupt saved state, disabled or unavailable selections, revoked or changed connection bindings, and incompatible effective metadata fail closed. Unknown pricing blocks Free and legacy verified-price Paid; configured Paid access can proceed without an estimate. An explicit pin is never silently replaced. Unrelated OpenCode agents keep their own selections. A newly discovered C absent from the running host inventory needs an explicit OpenCode reload/restart. It blocks with `OMC_HOST_MODEL_MISSING`; automatic roles may choose eligible already-loaded models. OpenCode 1.18.22/1.18.28 sanitize HTTP plugin failures to `UnknownError`, so the actionable reload guidance is a same-directory TUI toast/event. Headless consumers must read the instance event stream to receive that text. Model Control never disposes or restarts an OpenCode instance automatically. Changes to installed agent instructions/permissions, package or plugin paths, or the optional default agent require **Update connection** and an OpenCode restart. diff --git a/docs/opencode-integration.md b/docs/opencode-integration.md index 328c568..fc6c237 100644 --- a/docs/opencode-integration.md +++ b/docs/opencode-integration.md @@ -30,6 +30,8 @@ Startup refreshes stale metadata before initialization completes; a live service 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. +For a custom provider that maps models to different endpoints or SDK adapters, any change to that model-to-route mapping requires reviewing its saved pins and billing declaration. Adding a model to such a mixed provider can also invalidate the binding conservatively. New models on an unchanged shared endpoint retain the existing binding. Quota from an old binding is discarded. + 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. @@ -159,7 +161,7 @@ Exact pricing and expiry follow the discovery boundary above. Supplemental publi All four stable managed agents are installed without baked-in `model` fields: `omc-router`, `omc-code-worker`, `omc-vision-worker`, and `omc-reviewer`. The local plugin reads coherent saved settings/catalog state for every owned turn, including text, specialist tasks, and ordinary resumed tasks. It intersects eligible catalog models with the current OpenCode instance's loaded provider inventory. Saving A → B takes effect on the next owned turn when B is already loaded; ordinary policy changes do not rewrite config or require reconnecting. -At `chat.params`, the plugin rechecks current policy, price expiry, loaded inventory, exact provider/model/API identity, endpoint/transport, effective capabilities, and effective rates before inference. Missing or corrupt saved state, disabled or unavailable selections, incompatible effective metadata, and unknown pricing fail closed. An explicit pin is never silently replaced. Unrelated OpenCode agents keep their own selections. +At `chat.params`, the plugin rechecks current policy, price expiry, loaded inventory, exact provider/model/API identity, endpoint/transport, effective capabilities, and effective rates before inference. Missing or corrupt saved state, disabled or unavailable selections, revoked or changed connection bindings, and incompatible effective metadata fail closed. Unknown pricing blocks Free and legacy verified-price Paid; configured Paid access can proceed without an estimate. An explicit pin is never silently replaced. Unrelated OpenCode agents keep their own selections. A newly discovered C absent from the running host inventory needs an explicit OpenCode reload/restart. It blocks with `OMC_HOST_MODEL_MISSING`; automatic roles may choose eligible already-loaded models. OpenCode 1.18.22/1.18.28 sanitize HTTP plugin failures to `UnknownError`, so the actionable reload guidance is a same-directory TUI toast/event. Headless consumers must read the instance event stream to receive that text. Model Control never disposes or restarts an OpenCode instance automatically. Changes to installed agent instructions/permissions, package or plugin paths, or the optional default agent require **Update connection** and an OpenCode restart. 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 f06001a..e23f3e2 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 @@ -118,3 +118,9 @@ Baseline: `26c4dd912aafa33a932a40fc4d0d9991d7e7b4fa`. Completed the four remaini - Local candidate SHA-256: `668d3b8ab17c35abdaf3e3069e8dba0b177c381257b01408262a0049707c565f`. Exact packaged acceptance is running separately; this is candidate evidence, not final-release bytes or a publication claim. The preexisting untracked Grok plan is preserved. Merge, final artifact creation, publication, and public verification remain separate release gates. + +## Final release review (2026-09-12) + +User authorized final review, protected-main merge, and publication. Independent review found mixed-endpoint providers could change a model endpoint or swap SDK mappings while retaining the old binding revision. The fix hashes sorted per-model normalized API identities for mixed providers and clears quota when a binding changes. Homogeneous same-route additions keep their old revision. Mixed-provider inventory changes conservatively require reviewing pins/declarations. Core and hook regressions proved the defect before the fix; a separate reviewer found no remaining blocker in the patch. README/integration/support documentation now distinguishes configured Paid access from price-gated policies and dates historical acceptance accurately. + +Local full verification passed on Node 24.14.0 with OpenCode 1.18.22 available. Fresh candidate CI and a separate final-mode protected-main artifact run are required before publication. Historical candidate digest/evidence above is not evidence for this changed source. No maintainer global installation or real-provider inference is used by release acceptance. diff --git a/docs/support-matrix.md b/docs/support-matrix.md index 80066fa..b02707d 100644 --- a/docs/support-matrix.md +++ b/docs/support-matrix.md @@ -1,8 +1,12 @@ # Support matrix -This matrix describes implemented 0.3.0 behavior and dated compatibility evidence. Source verification, final installed-artifact acceptance, and public-channel verification are separate claims. See [Releasing](releasing.md) for the final-byte gates and the [release index](https://github.com/BitL8-ByteShort/opencode-model-control/releases) for published versions. +This matrix describes implemented 0.4.0 behavior and dated compatibility evidence. Source verification, final installed-artifact acceptance, and public-channel verification are separate claims. See [Releasing](releasing.md) for the final-byte gates and the [release index](https://github.com/BitL8-ByteShort/opencode-model-control/releases) for published versions. -## Platform and artifact evidence +## 0.4.0 verification boundary + +The release targets the same Linux/macOS and Node 22.12.0/24.x matrix, with OpenCode 1.18.22 and 1.18.28. Candidate [CI run 34304377304](https://github.com/BitL8-ByteShort/opencode-model-control/actions/runs/34304377304) verified 20 host scenarios and 57 synthetic loopback requests per host, 14 production browser interactions, and actual 0.3.0 Free/Paid upgrades plus legacy 0.2.1 coverage under every OS/Node combination. This dated candidate result predates the final mixed-endpoint binding correction and is not final-byte evidence. Final and public-download evidence belongs with the immutable release assets after the release checklist passes; no additional platform claim is made here. + +## Historical 0.3.0 platform and artifact evidence Final [CI run 34146977662](https://github.com/BitL8-ByteShort/opencode-model-control/actions/runs/34146977662) passed all seven jobs on 2026-09-07 at source commit [`bcd228a349627523382dda65017bab6fb0a4856c`](https://github.com/BitL8-ByteShort/opencode-model-control/commit/bcd228a349627523382dda65017bab6fb0a4856c). The final 0.3.0 tarball was built once from that clean protected-main commit, and every installed-artifact job consumed the same bytes: SHA-256 `26a532b44c96d643c0543a78d2fef1ab2c1a3b83886e6715cef0ab683d3413ab`. @@ -39,19 +43,19 @@ A clean version-pinned npm name install reported CLI 0.3.0 and all 74 package fi | --- | --- | --- | | All-provider discovery | Plugin-aware `opencode models --verbose`, no provider filter | `--pure` fallback is explicitly incomplete; preserve last usable records. OpenCode may normalize its own `$schema` line. | | Metadata refresh | Stale startup, every 15 minutes while active, or manual Update | Cross-process coalescing; separate attempted/successful timestamps; no inference or inferred settings/config writes. | -| Pricing | Exact provider/model/API match; complete rates across every supported supplied billing dimension | With complete valid evidence, any positive rate means paid; exact-zero valid public evidence means free; malformed/conflicting/expired pricing is unknown and blocked. No free-name roster. | +| Pricing | Exact provider/model/API match; complete rates across every supported supplied billing dimension | With complete valid evidence, any positive rate means paid; exact-zero valid public evidence means free; malformed/conflicting/expired pricing is unknown and cannot authorize Free or legacy verified-price Paid. Configured Paid uses eligible host connection evidence without requiring an estimate. No free-name roster. | | Pricing freshness | 24-hour expiry evaluated at routing time | Successful 200/304 renews public evidence; failed requests do not. Neither the source nor OMC guarantees future billing. | | Capabilities | Effective OpenCode report plus separate supplemental public report | Unknown differs from false; full modalities/tools/reasoning/options/structured-output/limits retained. Supplemental metadata cannot expand effective restrictions. | -| Inclusion | Default-on auto-include follows saved Free/Paid policy | Explicit disables win. Saved Paid permits future eligible known-paid models; explicit enables cannot bypass hard gates. | -| Saved intent | v3 policy/enabled/disabled controls; private legacy backups | Preserve absent pins, disables and Paid policy; revision-aware Save prevents lost updates; refresh/conflicts retain drafts. | +| Inclusion | Default-on auto-include follows saved Free/Paid policy | Explicit disables win. Configured Paid permits future eligible configured models; explicit enables cannot bypass hard gates. | +| Saved intent | v4 policy/enabled/disabled controls, billing declarations and role bindings; private legacy backups | Preserve absent pins, disables and Paid policy; revision-aware Save prevents lost updates; refresh/conflicts retain drafts. | | Live owned-role routing | Stable model-free agents, coherent saved state and pre-inference revalidation | Loaded A → B changes need no reconnect; explicit C absent from host inventory blocks until an explicit reload. No automatic disposal. | | Media handling | Tool-free vision analysis; explicit user-authored code intent may retain router | Classifier reads at most 4,000 user-text characters and attachment type/MIME only; no attachment payload/location inspection. | | Delegation and repair | Bounded worker → independent read-only reviewer → one authorized repair | Current runtime guards and exact completion evidence; synthesis remains model-guided; no restart-durable workflow claim. | | Owned slash summaries | Narrow exact-message one-shot grant after matching child completion | Changed parent pin blocks the stale inherited summary; no general synthetic-message bypass. | -| Connect/Disconnect | Receipt-owned managed surface 2, exact MCP preflight, private backup, conflict refusal | Preserves unrelated JSONC/plugins/defaults; receipt is ownership evidence, not package authenticity. | -| Usage | Fixed local aggregate query, 7/30/90-day or all-time windows | Recorded cost is an estimate, not an invoice; no prompt/content projection. | +| Connect/Disconnect | Receipt-owned managed surface 3, exact MCP preflight, private backup, conflict refusal | Preserves unrelated JSONC/plugins/defaults; receipt is ownership evidence, not package authenticity. | +| Usage | Fixed local aggregate query, 7/30/90-day or all-time windows | OpenCode-recorded cost is not an invoice. Captured records retain historical billing and binding, nullable token/cost fields and currency; no prompt/content projection. Quota and estimates remain unreported without evidence. | | Runtime access check | Explicitly acknowledged bounded synthetic OpenCode run | OpenCode may retry; can incur real costs/retention; never automatic or quality evidence. | | Ranking and benchmark qualification | Existing ranking and curated restrictions retained | No new winner, benchmark campaign, or quality promotion. | | Provider authentication integrations | Deferred | OpenCode retains credentials/authentication authority; no direct OpenRouter account/catalog integration. | -“Free” does not imply private inference, unlimited use, entitlement, uptime, or perpetual pricing. Model names and availability can change. The running catalog and unexpired exact evidence govern eligibility, not a hardcoded list. +“Free” does not imply private inference, unlimited use, entitlement, uptime, or perpetual pricing. Model names and availability can change. The running catalog, current connection evidence and saved policy govern eligibility. Free and legacy verified-price Paid additionally require unexpired exact pricing evidence; no hardcoded free-model list authorizes access. diff --git a/src/core/connections.js b/src/core/connections.js index 4ba04b7..ec7a096 100644 --- a/src/core/connections.js +++ b/src/core/connections.js @@ -65,6 +65,7 @@ export function deriveBindingRevision(input) { authKind: input?.authKind ?? "unknown", billingKind: input?.billingKind ?? "unknown", mixed: input?.mixed === true, + ...(input?.mixed === true ? { modelRoutes: input?.modelRoutes ?? null } : {}), }), ) .digest("hex") @@ -207,10 +208,11 @@ export function applyBillingDeclarations(connections, declarations = {}) { } export function connectionBindingInputs(provider) { - const models = Object.values(provider?.models ?? {}); - const identities = models.map((model) => normalizeApiIdentity(model?.api)); + const modelEntries = Object.entries(provider?.models ?? {}).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0); + const identities = modelEntries.map(([, model]) => normalizeApiIdentity(model?.api)); const npms = [ - ...new Set(identities.map((api) => api.npm).filter((value) => value)), + ...new Set(identities.map((api) => api.npm ?? null)), ].sort(); const urls = [ ...new Set(identities.map((api) => api.url).filter((value) => value !== undefined)), @@ -220,6 +222,13 @@ export function connectionBindingInputs(provider) { npm: npms.length === 1 ? npms[0] : null, url: !mixed && urls.length <= 1 ? (urls[0] ?? null) : null, mixed, + // A mixed provider's endpoint set is insufficient: swapping two models' + // routes must invalidate saved pins too. Hash only normalized public API + // identity; model options and credentials never enter binding evidence. + // Homogeneous providers omit this digest so same-route discovery is stable. + modelRoutes: mixed ? createHash("sha256").update(JSON.stringify( + modelEntries.map(([key], index) => ({ model: key, ...identities[index] })), + )).digest("hex") : null, declared: identities.some((api) => api.urlValid && api.url !== null), }; } diff --git a/src/opencode/connection-observer.js b/src/opencode/connection-observer.js index 40f88aa..a889a6c 100644 --- a/src/opencode/connection-observer.js +++ b/src/opencode/connection-observer.js @@ -70,6 +70,7 @@ export function observeConnections({ authKind, billingKind, mixed: binding.mixed, + modelRoutes: binding.modelRoutes, }); const billingUnchanged = prior && @@ -96,7 +97,7 @@ export function observeConnections({ prior?.entitlement === "reported-revoked" ? "reported-revoked" : "not-reported", - quota: prior?.quota ?? null, + quota: prior?.bindingRevision === bindingRevision ? (prior.quota ?? null) : null, }), ); } diff --git a/test/core/connections.test.js b/test/core/connections.test.js index 24a1b9f..cdc8e47 100644 --- a/test/core/connections.test.js +++ b/test/core/connections.test.js @@ -194,3 +194,50 @@ test("quota observations reject invalid numbers and mixed provenance is not inve }); assert.equal(snapshot.connections[0].quota, null); }); + +test("mixed provider bindings retain each model's endpoint and SDK mapping", () => { + const api = (id, url, npm = "@ai-sdk/openai-compatible") => ({ id, url, npm }); + const models = { + a: api("a", "https://subscription.invalid/v1"), + b: api("b", "https://other.invalid/v1"), + }; + const observe = (value, previousConnections = []) => observeConnections({ + scopeId, now, previousConnections, providers: [provider("gateway", value)], + }); + const initial = observe(models); + initial[0].billing = { kind: "subscription", source: "user-declared", observedAt: new Date(now).toISOString() }; + for (const changed of [ + { ...models, a: api("a", "https://metered.invalid/v1") }, + { a: api("a", models.b.url), b: api("b", models.a.url) }, + { ...models, a: api("a", models.a.url, "@ai-sdk/openai") }, + { ...models, c: api("c", models.a.url) }, + ]) { + const result = observe(changed, initial); + assert.notEqual(result[0].bindingRevision, initial[0].bindingRevision); + assert.equal(result[0].billing.kind, "unknown"); + assert.doesNotMatch(JSON.stringify(result), /subscription\.invalid|metered\.invalid|other\.invalid/); + } + assert.equal(observe({ b: models.b, a: models.a })[0].bindingRevision, initial[0].bindingRevision); + + const mixedSDKs = { a: api("a", models.a.url, "@ai-sdk/openai"), b: api("b", models.a.url, "@ai-sdk/anthropic") }; + const swappedSDKs = { a: api("a", models.a.url, "@ai-sdk/anthropic"), b: api("b", models.a.url, "@ai-sdk/openai") }; + assert.notEqual(observe(mixedSDKs)[0].bindingRevision, observe(swappedSDKs)[0].bindingRevision); +}); + +test("homogeneous provider bindings remain stable when models share the same route", () => { + const a = { id: "a", npm: "@ai-sdk/openai-compatible", url: "https://same.invalid/v1" }; + const observe = models => observeConnections({ scopeId, now, providers: [provider("gateway", models)] })[0].bindingRevision; + assert.equal(observe({ a }), observe({ b: { ...a, id: "b" }, a })); + assert.equal(observe({ a }), deriveBindingRevision({ npm: a.npm, url: a.url })); +}); + +test("quota is retained only while its observed connection binding is unchanged", () => { + const models = { a: { id: "a", npm: "@ai-sdk/openai", url: "https://first.invalid/v1" } }; + const observe = (value, previousConnections = []) => observeConnections({ scopeId, now, + providers: [provider("gateway", value)], previousConnections }); + const initial = observe(models); + initial[0].quota = { source: "host", unit: "requests", limit: 100, used: 10, remaining: 90, + resetsAt: null, observedAt: new Date(now).toISOString(), expiresAt: new Date(now + 60_000).toISOString() }; + assert.deepEqual(observe(models, initial)[0].quota, initial[0].quota); + assert.equal(observe({ a: { ...models.a, url: "https://second.invalid/v1" } }, initial)[0].quota, null); +}); diff --git a/test/opencode/live-routing-v3.test.js b/test/opencode/live-routing-v3.test.js index 1ba8694..8d6f35d 100644 --- a/test/opencode/live-routing-v3.test.js +++ b/test/opencode/live-routing-v3.test.js @@ -7,6 +7,7 @@ import { validateCatalog, } from "../../src/core/index.js"; import { deriveConnectionId } from "../../src/core/connections.js"; +import { observeConnections } from "../../src/opencode/connection-observer.js"; import { createMediaRoutingHooks, resolveMediaWorker, @@ -327,6 +328,65 @@ test("repair stops when the original connection binding switches billing", async { code: "OMC_DISPATCH_IDENTITY_CONFLICT" }, ); }); + +function mixedConnectionFixture() { + const connectionScopeId = "11111111-1111-4111-8111-111111111111"; + const connections = []; + const f = fixture({ connections, connectionScopeId }); + f.settings.costPolicy = "known-cost"; + f.settings.paidEligibility = "configured-connections"; + const changeEndpoint = url => { + f.catalog.models.find(model => model.id === A).api.url = url; + f.host.find(model => `opencode/${model.id}` === A).api.url = url; + }; + changeEndpoint("https://subscription.invalid/v1"); + connections.push(...observeConnections({ + scopeId: connectionScopeId, + providers: [{ id: "opencode", models: Object.fromEntries(f.host.map(model => [model.id, model])) }], + })); + return { ...f, connection: connections[0], changeEndpoint }; +} + +test("mixed-provider endpoint changes block saved pins and in-flight dispatch after catalog refresh", async () => { + const f = mixedConnectionFixture(); + f.settings.roleAssignments["code-worker"] = A; + f.settings.roleConnections["code-worker"] = { + connectionId: f.connection.id, bindingRevision: f.connection.bindingRevision, + }; + const output = await f.turn(); + f.changeEndpoint("https://metered.invalid/v1"); + await assert.rejects(f.dispatch(output), { code: "OMC_ROUTE_UNAVAILABLE" }); + await assert.rejects(f.turn(), { code: "OMC_ROUTE_UNAVAILABLE" }); +}); + +test("mixed-provider endpoint changes cannot move a retained repair to a new route", async () => { + const f = mixedConnectionFixture(); + await f.turn("omc-router", "parent"); + await f.hooks["tool.execute.before"]( + { tool: "task", sessionID: "parent", callID: "work" }, + { args: { subagent_type: "omc-code-worker" } }, + ); + await f.dispatch(await f.turn()); + await f.hooks["tool.execute.after"]( + { tool: "task", sessionID: "parent", callID: "work" }, + { metadata: { sessionId: "child" } }, + ); + await f.hooks["tool.execute.before"]( + { tool: "task", sessionID: "parent", callID: "review" }, + { args: { subagent_type: "omc-reviewer" } }, + ); + await f.turn("omc-reviewer", "review"); + await f.hooks["tool.execute.after"]( + { tool: "task", sessionID: "parent", callID: "review" }, + { metadata: { sessionId: "review" } }, + ); + await f.hooks["tool.execute.before"]( + { tool: "task", sessionID: "parent", callID: "repair" }, + { args: { subagent_type: "omc-code-worker", task_id: "child" } }, + ); + f.changeEndpoint("https://metered.invalid/v1"); + await assert.rejects(f.turn(), { code: "OMC_DISPATCH_IDENTITY_CONFLICT" }); +}); test("unrelated agents never load saved policy or host inventory", async () => { const hooks = createMediaRoutingHooks({ loadPolicy: async () => {